Skip to content

Instantly share code, notes, and snippets.

@yarastqt
Created July 26, 2021 10:34
Show Gist options
  • Select an option

  • Save yarastqt/73e3ba9fc01ced316e4e536943bccd3c to your computer and use it in GitHub Desktop.

Select an option

Save yarastqt/73e3ba9fc01ced316e4e536943bccd3c to your computer and use it in GitHub Desktop.
import { useState } from 'react'
import { useUniqId } from '../../libs/useUniqId'
export function useSelectState(props: any) {
const { children } = props
const [isOpened, setOpened] = useState(false)
const [selected, setSelected] = useState<{ option: any; value: any }>({} as any)
const [focusedIndex, setFocusedIndex] = useState(-1)
const [selectedIndex, setSelectedIndex] = useState(-1)
const total = children.length - 1
const $collection = children.map((child: any, index: any) => {
return {
key: index,
index,
value: child.props.value,
children: child.props.children,
}
})
return {
collection: $collection,
isOpened,
selected,
toggleOpened: () => {
setOpened(!isOpened)
},
setOpened: () => {
setOpened(true)
setFocusedIndex(selectedIndex > 0 ? selectedIndex : 0)
},
setClosed: () => {
setOpened(false)
setFocusedIndex(-1)
},
focusNext: () => {
const next = focusedIndex + 1
setFocusedIndex(next > total ? 0 : next)
},
focusPrev: () => {
const next = focusedIndex - 1
setFocusedIndex(next < 0 ? total : next)
},
select: (index?: number) => {
if (index !== undefined) {
setSelectedIndex(index)
setFocusedIndex(index)
} else {
setSelectedIndex(focusedIndex)
}
const target = $collection[index ?? focusedIndex]
setSelected({
value: target.value,
option: target.children,
})
setOpened(false)
},
isFocused: (index: number) => {
return focusedIndex === index
},
isSelected: (index: number) => {
return selectedIndex === index
},
}
}
export function useSelect(props: any, state: any) {
const uniqId = useUniqId()
return {
triggerProps: {
'aria-haspopup': 'listbox',
'aria-expanded': state.isOpened,
'aria-controls': state.isOpened ? uniqId : undefined,
onPressStart: () => {
if (state.isOpened) {
state.setClosed()
} else {
state.setOpened()
}
},
onKeyDown: (event: any) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
state.setOpened()
}
},
},
menuProps: {
id: uniqId,
state,
role: 'listbox',
// autoFocus: true,
onBlur: (event: any) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
event.preventDefault()
state.setClosed()
}
},
},
}
}
@yarastqt
Copy link
Copy Markdown
Author

useMenu.ts

import { HTMLAttributes, useState } from 'react'

import { usePress } from '../../interactions/press'
import { useFocusable } from '../../interactions/focusable'

export function useMenu(props: any, state: any, menuRef: any) {
  const { autoFocus, id, role = 'menu', onBlur, tabIndex = 0 } = props
  const { focusableProps } = useFocusable({ autoFocus }, menuRef)

  const menuProps: HTMLAttributes<HTMLElement> = {
    onKeyDown: (event) => {
      if (event.key === 'ArrowDown') {
        event.preventDefault()
        state.focusNext()
      }
      if (event.key === 'ArrowUp') {
        event.preventDefault()
        state.focusPrev()
      }
      if (event.key === 'Space') {
        event.preventDefault()
        state.select()
      }
    },
  }

  return {
    menuProps: {
      onBlur,
      id,
      ...focusableProps,
      ...menuProps,
      tabIndex: state.isVirtualFocus ? undefined : tabIndex,
      role,
    },
  }
}

export function useMenuItem(props: any, state: any, ref: any) {
  const { index, id } = props
  const isFocused = state.isFocused(index)
  const isSelected = state.isSelected(index)
  const autoFocus = state.isVirtualFocus ? false : isFocused
  const { focusableProps } = useFocusable({ autoFocus }, ref)
  const { pressProps } = usePress({
    onPress: () => {
      state.select(index)
    },
  })

  return {
    isFocused,
    isSelected,
    itemProps: {
      id,
      ...pressProps,
      ...focusableProps,
      'aria-selected': isSelected,
      role: 'option',
      // eslint-disable-next-line no-nested-ternary
      tabIndex: state.isVirtualFocus ? undefined : isFocused ? 0 : -1,
    },
  }
}

@yarastqt
Copy link
Copy Markdown
Author

useUniqId.ts

import { createContext, useContext, useMemo } from 'react'

// import { canUseDOM } from '../lib/canUseDOM';
// import { SSRContext, initialContextValue } from '../ssr';

export type SSRContextValue = {
  /**
   * Текущее значение счетчика внутри контекста
   */
  value: number
  /**
   * ID контекста, необходим при использовании вложенных `SSRProvider`
   */
  id: number
}

export const initialContextValue: SSRContextValue = { value: 0, id: 0 }

export const SSRContext = createContext<SSRContextValue>(initialContextValue)

/**
 * Реакт-хук для генерации уникального id.
 *
 * При использовании в проекте ssr необходимо использовать
 * `SSRProvider` для синхронизации id между сервером и клиентом.
 *
 * @param prefix - Префикс для генерации уникального id (по умолчанию `xuniq`)
 *
 * @example
 * const id = useUniqId()
 */
export function useUniqId(prefix: string = 'xuniq'): string {
  const context = useContext(SSRContext)
  // NOTE: Не используем кэширование через useRef или useState,
  // т.к. в таком случае происходит постоянный инкремент значения.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const id = useMemo(() => `${prefix}-${context.id}-${++context.value}`, [])

  // console.assert(
  //   canUseDOM() || context !== initialContextValue,
  //   'При серверном рендеринге необходимо обернуть приложение в <SSRProvider>' +
  //     ' для синхронизации id между сервером и клиентом.',
  // )

  return id
}

@yarastqt
Copy link
Copy Markdown
Author

Select.tsx

import { useSelectState, useSelect } from 'react-web-sdk'
import { Button, ArrowShortBottom, ArrowShortTop, Popup, MenuBase } from 'ui-kit'

export const Select = (props) => {
  const state = useSelectState(props)
  const { triggerProps, menuProps } = useSelect(props, state)
  const arrowIcon = state.isOpened ? <ArrowShortTop /> : <ArrowShortBottom />

  return (
    <>
      <Button {...triggerProps} addonAfter={arrowIcon}>
        {state.selected.option ?? 'Select option'}
      </Button>
      <Popup visible={state.isOpened}>
        <MenuBase {...menuProps} />
      </Popup>
    </>
  )
}

@yarastqt
Copy link
Copy Markdown
Author

Menu.tsx

import { forwardRef, useRef } from 'react'
import { useMenu, useMenuItem, useHover, useForkRef } from 'react-web-sdk'
import { Check } from 'ui-kit'

import './Menu.css'

export const MenuBase = forwardRef((props, ref) => {
  const { state } = props
  const menuRef = useRef(null)
  const { menuProps } = useMenu(props, state, ref)
  const forkedRef = useForkRef(menuRef, ref)

  return (
    <ul {...menuProps} ref={forkedRef} className="Menu">
      {state.collection.map((item) => (
        <MenuItem {...item} state={state} />
      ))}
    </ul>
  )
})

const MenuItem = (props) => {
  const { children, state } = props
  const ref = useRef(null)
  const { itemProps, isFocused, isSelected } = useMenuItem(props, state, ref)
  const { isHovered, hoverProps } = useHover(props)

  return (
    <li
      {...itemProps}
      {...hoverProps}
      ref={ref}
      className="Menu-Item"
      data-selected={isSelected}
      data-focused={isFocused}
      data-hovered={isHovered}
    >
      {children}
      {isSelected && <Check />}
    </li>
  )
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment