diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx index d702686..99b8b2e 100644 --- a/src/components/Portal/Portal.tsx +++ b/src/components/Portal/Portal.tsx @@ -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; 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(null); - const { showModal, toggleElement, reorgPos } = usePortal({ portalRef }); + const { showModal, setShowModal, toggleElement, reorgPos } = usePortal({ portalRef }); const [items, setItems] = useState([]); const ACTIONS: Record) => void> = { ArrowDown: () => focus('next'), ArrowUp: () => focus('prev'), - Tab: e => e.preventDefault(), + Tab: () => focus('next'), Enter: () => select(), + Escape: () => setShowModal(false), }; const handleOnKeyDown = (e: React.KeyboardEvent) => { - 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); } }; @@ -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('[data-focus-enabled="true"]')).filter(e => { - return e.getAttribute('disabled') == null; - }), - ); + if (!disabledAutoFocus) portalRef.current.focus(); + + const newItems = Array.from( + portalRef.current.querySelectorAll('[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 ( width && `width: ${typeof width === 'string' ? width : `${width}px`};`} + max-width: 100%; padding: 0.25rem; border-radius: 0.25rem; position: fixed; diff --git a/src/components/Portal/PortalContent.tsx b/src/components/Portal/PortalContent.tsx index 99d5b15..99421b7 100644 --- a/src/components/Portal/PortalContent.tsx +++ b/src/components/Portal/PortalContent.tsx @@ -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; - 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 = () => { @@ -26,7 +27,14 @@ export const PortalContent = ({ children, ref, width, disabledBG = false, onKeyD createPortal( <> {!disabledBG && } - + {children} , diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 0000000..860f28b --- /dev/null +++ b/src/components/Search/Search.tsx @@ -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 ( + + {children} + + ); +}; diff --git a/src/components/Search/SearchContent.tsx b/src/components/Search/SearchContent.tsx new file mode 100644 index 0000000..4abb889 --- /dev/null +++ b/src/components/Search/SearchContent.tsx @@ -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(({ width, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +SearchContent.displayName = 'SearchContent'; diff --git a/src/components/Search/SearchInput.tsx b/src/components/Search/SearchInput.tsx new file mode 100644 index 0000000..993d011 --- /dev/null +++ b/src/components/Search/SearchInput.tsx @@ -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(null); + + React.useEffect(() => { + if (inputRef.current) inputRef.current.focus(); + }, []); + + return ; +}; + +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; +`; diff --git a/src/components/Search/SearchInputToggle.tsx b/src/components/Search/SearchInputToggle.tsx new file mode 100644 index 0000000..19d1af8 --- /dev/null +++ b/src/components/Search/SearchInputToggle.tsx @@ -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 = React.ComponentPropsWithoutRef; + +/** + * SearchInputToggle + * @returns + */ +export const SearchInputToggle = React.forwardRef>( + ({ onClick, onChange, onKeyDown, ...props }, ref) => { + const { showModal, setShowModal, setToggleElment } = useContext(PortalContext); + const compRef = React.useRef(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) => void> = { + ArrowDown: () => focus('next'), + ArrowUp: () => focus('prev'), + Tab: () => closeModal(), + Enter: () => focus('next'), + Escape: () => closeModal(), + }; + + const handleOnKeyDown = (e: React.KeyboardEvent) => { + 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 ( + + ); + }, +); +SearchInputToggle.displayName = 'SearchInputToggle'; diff --git a/src/components/Search/SearchItem.tsx b/src/components/Search/SearchItem.tsx new file mode 100644 index 0000000..d13636a --- /dev/null +++ b/src/components/Search/SearchItem.tsx @@ -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(({ disabled = false, ...props }, ref) => { + const { setShowModal } = useContext(PortalContext); + + const onClickHandler = () => { + if (!disabled) setShowModal(false); + }; + + return ( + + ); +}); +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); + } + `} +`; diff --git a/src/components/Search/SearchToggle.tsx b/src/components/Search/SearchToggle.tsx new file mode 100644 index 0000000..eeb02d6 --- /dev/null +++ b/src/components/Search/SearchToggle.tsx @@ -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 = React.ComponentPropsWithoutRef & { + asChild?: boolean; +}; + +/** + * SearchToggle + * @returns + */ +export const SearchToggle = React.forwardRef>( + ({ asChild, onClick, ...props }, ref) => { + const { setShowModal, setToggleElment } = useContext(PortalContext); + const compRef = React.useRef(null); + + const Comp = asChild ? Slot : Button; + + return ( + { + if (compRef.current) { + const rect = compRef.current; + setToggleElment(rect); + } + + setShowModal(true); + })} + {...props} + /> + ); + }, +); +SearchToggle.displayName = 'SearchToggle'; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx new file mode 100644 index 0000000..e15448c --- /dev/null +++ b/src/components/Search/index.tsx @@ -0,0 +1,40 @@ +import styled from '@emotion/styled'; + +/* eslint-disable react-refresh/only-export-components */ +export * from './Search'; +export * from './SearchContent'; +export * from './SearchToggle'; +export * from './SearchItem'; +export * from './SearchInput'; +export * from './SearchInputToggle'; + +export const SearchLabel = styled.h3` + font-weight: 600; + font-size: 0.75rem; + line-height: 1rem; + padding: 0.375rem 0.5rem; + margin: 0px; +`; + +export const SearchDivider = styled.div` + height: 1px; + margin: 0.25rem -0.25rem; + background: var(--gray-300); +`; +export const SearchDescription = styled.p` + margin: 0.375rem 0px; + font-weight: 300; +`; + +export const SearchShortcut = styled.span` + font-size: 0.75rem; + letter-spacing: 0.1em; + line-height: 1rem; + margin-left: auto; + opacity: 0.5; +`; + +export const SearchItemList = styled.ul` + margin: 0px; + padding: 0px; +`; diff --git a/src/components/Select/SelectItem.tsx b/src/components/Select/SelectItem.tsx index be5b83a..eca7833 100644 --- a/src/components/Select/SelectItem.tsx +++ b/src/components/Select/SelectItem.tsx @@ -51,6 +51,7 @@ export const SelectItem = React.forwardRef( aria-selected={selected} selected={selected} onClick={composeEventHandlers(props.onClick, onClickHandler)} + data-focus-enabled="true" {...props} > {children} @@ -83,7 +84,8 @@ const SelectItemStyled = styled.li<{ selected: boolean; disabled: boolean }>` opacity: 0.5; ` : css` - &[aria-selected='false']:hover { + &[aria-selected='false']:hover, + &[aria-selected='false']:focus { background-color: var(--gray-100); } `} diff --git a/src/components/index.ts b/src/components/index.ts index ecbae11..321d94c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,6 +8,7 @@ export * from './HoverCard'; export * from './Input'; export * from './Label'; export * from './Pagination'; +export * from './Search'; export * from './Select'; export * from './Switch'; export * from './Textarea'; diff --git a/src/stories/common/Search.stories.tsx b/src/stories/common/Search.stories.tsx new file mode 100644 index 0000000..06a5199 --- /dev/null +++ b/src/stories/common/Search.stories.tsx @@ -0,0 +1,191 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { + Search, + SearchContent, + SearchDivider, + SearchInput, + SearchInputToggle, + SearchItem, + SearchItemList, + SearchLabel, + SearchShortcut, + SearchToggle, +} from '../..'; + +const meta = { + title: 'common/Search', + component: Search, + tags: ['autodocs'], + args: { + align: 'center', + }, + parameters: { + layout: 'centered', + componentSubtitle: 'Base Search', + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const animals = [ + 'dog', + 'cat', + 'elephant', + 'lion', + 'tiger', + 'bear', + 'giraffe', + 'zebra', + 'monkey', + 'snake', + 'rabbit', + 'horse', + 'deer', + 'fox', + 'wolf', +]; + +export const Default: Story = { + args: {}, + decorators: [ + () => { + const [text, setText] = React.useState(''); + return ( + + { + setText(e.target.value); + }} + /> + + + Social + GitHub + Facebook + + User + {animals + .filter(animal => animal.includes(text)) + .slice(0, 5) + .map(item => ( + {item} + ))} + + + + ); + }, + ], +}; + +const TempSearch = () => { + const [text, setText] = React.useState(''); + + return ( + <> + + Search Input / + + + { + setText(e.target.value); + }} + /> + + Social + + GitHub + ⌘+T + + Facebook + + User + @bandmators + @bmates + {animals + .filter(animal => animal.includes(text)) + .slice(0, 5) + .map(item => ( + {item} + ))} + + + + ); +}; + +export const Toggle: Story = { + args: {}, + decorators: [ + () => { + return ( + + + + ); + }, + ], +}; + +export const Space: Story = { + parameters: { + docs: { + description: { + story: 'space is `number` type. ``', + }, + }, + }, + args: { + align: 'start', + space: 16, + children: , + }, +}; + +export const AlignStart: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: { + align: 'start', + children: , + }, +}; + +export const AlignCenter: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: { + align: 'center', + children: , + }, +}; + +export const AlignEnd: Story = { + parameters: { + docs: { + description: { + story: '``', + }, + }, + }, + args: { + align: 'end', + children: , + }, +};