Last active
November 18, 2021 19:29
-
-
Save elshanx/d25b3ac993d7e22762020b4bec01db67 to your computer and use it in GitHub Desktop.
a custom accessible typed select in react with react-hook-form and refs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { KeyboardEvent } from 'react' | |
import { useCallback, useEffect, useRef, useState } from 'react' | |
import type { FieldError, Path, UseFormClearErrors, UseFormSetValue } from 'react-hook-form' | |
import OutsideClickHandler from 'react-outside-click-handler' | |
import styled from 'styled-components' | |
import InputError from '@/components/shared/InputError' | |
type SelectOption = { | |
value: string | |
option: string | |
} | |
type Props<T> = { | |
setValue: UseFormSetValue<T> | |
id: Path<T> | |
options: SelectOption[] | |
start?: number | |
hasError: boolean | |
error: FieldError | undefined | |
isSubmitSuccessful: boolean | |
clearErrors: UseFormClearErrors<T> | |
defaultValue?: string | |
} | |
const Select = <T,>({ | |
setValue, | |
id, | |
options, | |
start, | |
hasError, | |
error, | |
isSubmitSuccessful, | |
clearErrors, | |
defaultValue, | |
}: Props<T>) => { | |
const [isOpen, setIsOpen] = useState(false) | |
const [selectedOption, setSelectedOption] = useState(() => { | |
return options.find(option => option.value === defaultValue)?.option || 'Seçim edin' | |
}) | |
const [selectedIndex, setSelectedIndex] = useState(start || 0) | |
const itemsRef = useRef<HTMLLIElement[]>([] as HTMLLIElement[]) | |
const toggleSelect = useCallback(() => setIsOpen(s => !s), []) | |
const closeSelect = useCallback(() => setIsOpen(false), []) | |
const handleBlur = useCallback(() => clearErrors(id), [clearErrors, id]) | |
const onOptionClick = useCallback( | |
(option: string, value: any, index: number) => { | |
setValue(id, value) | |
setSelectedOption(option) | |
setIsOpen(false) | |
setSelectedIndex(index) | |
}, | |
[id, setValue], | |
) | |
useEffect(() => { | |
if (isSubmitSuccessful) { | |
setSelectedOption('Seçim edin') | |
setSelectedIndex(0) | |
} | |
}, [isSubmitSuccessful]) | |
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { | |
const end = itemsRef.current.length - 1 | |
if (isOpen) { | |
if (e.key === 'ArrowDown') { | |
e.preventDefault() | |
itemsRef.current[(selectedIndex + 1) % options.length].focus() | |
setSelectedIndex(s => (s + 1) % options.length) | |
} else if (e.key === 'ArrowUp') { | |
e.preventDefault() | |
itemsRef.current[(options.length + selectedIndex - 1) % options.length].focus() | |
setSelectedIndex(s => (options.length + s - 1) % options.length) | |
} else if (e.key === 'Tab') { | |
setSelectedIndex(s => (s + 1) % options.length) | |
} | |
} | |
if (e.key === 'Enter') { | |
setSelectedOption(options[selectedIndex].option) | |
setValue(id, options[selectedIndex].value as any) | |
toggleSelect() | |
} else if (e.code === 'Space') { | |
e.preventDefault() | |
toggleSelect() | |
} else if (e.key === 'Escape') { | |
setIsOpen(false) | |
} else if (e.key === 'Home') { | |
e.preventDefault() | |
setSelectedIndex(0) | |
itemsRef.current[0].focus() | |
} else if (e.key === 'End') { | |
e.preventDefault() | |
setSelectedIndex(end) | |
itemsRef.current[end].focus() | |
} | |
} | |
return ( | |
<OutsideClickHandler onOutsideClick={closeSelect}> | |
<SelectContainer hasError={hasError} onBlur={handleBlur} onKeyDown={e => onKeyDown(e)}> | |
<SelectHeader | |
isDefault={selectedOption == 'Seçim edin'} | |
id={id} | |
role='combobox' | |
tabIndex={0} | |
aria-autocomplete='list' | |
aria-expanded={!!isOpen} | |
aria-label='Choose an option' | |
aria-required='true' | |
onClick={toggleSelect}> | |
{selectedOption} | |
</SelectHeader> | |
<SelectListContainer aria-orientation='vertical' isActive={!!isOpen}> | |
{options.map(({ option, value }, index) => ( | |
<ListItem | |
tabIndex={isOpen ? 0 : -1} | |
onClick={() => onOptionClick(option, value, index)} | |
ref={el => (itemsRef.current[index] = el!)} | |
key={value}> | |
{option} | |
</ListItem> | |
))} | |
</SelectListContainer> | |
</SelectContainer> | |
<InputError error={error?.message} /> | |
</OutsideClickHandler> | |
) | |
} | |
const SelectContainer = styled.div<{ hasError: boolean }>` | |
user-select: none; | |
height: 4.8rem; | |
width: 100%; | |
font-size: 1.4rem; | |
border-radius: 4px; | |
outline: none; | |
transition: all 200ms; | |
position: relative; | |
border: 1px solid ${({ hasError }) => (hasError ? 'red' : 'rgba(232, 232, 232, 1)')}; | |
` | |
const SelectHeader = styled.span<{ isDefault: boolean }>` | |
cursor: pointer; | |
height: 100%; | |
display: flex; | |
padding-inline-start: 1.6rem; | |
align-items: center; | |
margin-bottom: 8px; | |
background: #fff; | |
border-radius: 4px; | |
overflow: hidden; | |
transition: all 200ms; | |
${({ isDefault }) => isDefault && `color: rgba(30, 15, 52, 0.3)`} | |
` | |
const SelectListContainer = styled.ul<{ isActive: boolean }>` | |
position: absolute; | |
width: 100%; | |
z-index: 10; | |
transform: scale(${({ isActive }) => (isActive ? 1 : 0)}); | |
opacity: ${({ isActive }) => (isActive ? 1 : 0)}; | |
text-align: center; | |
overflow: hidden; | |
border-radius: 8px; | |
transition: all 200ms; | |
box-shadow: 0 10px 20px rgba(72, 84, 159, 0.08); | |
` | |
const ListItem = styled.li` | |
background: white; | |
padding: 1.5rem 2.4rem; | |
text-align: left; | |
transition: all 200ms; | |
border: 1px solid transparent; | |
border-radius: 8px; | |
cursor: pointer; | |
&:hover { | |
color: #f49919; | |
} | |
:not(:last-child) { | |
border-bottom: 1px solid rgba(236, 236, 236); | |
} | |
` | |
export default Select |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment