Skip to content

Instantly share code, notes, and snippets.

@elshanx
Last active November 18, 2021 19:29
Show Gist options
  • Save elshanx/d25b3ac993d7e22762020b4bec01db67 to your computer and use it in GitHub Desktop.
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
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