demo/src/react-ranker/ranker.tsx

Summary

Maintainability
C
7 hrs
Test Coverage
import type { HTMLAttributes } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CaretDownOutlined, CaretUpOutlined } from '@ant-design/icons'
import { animated, to, useSprings } from '@react-spring/web'
import { useDrag } from '@use-gesture/react'
import type { ReactDOMAttributes } from '@use-gesture/react/dist/declarations/src/types'
import _ from 'lodash'

import BucketBoxes from './bucket-boxes'
import {
  B_GUTTER,
  B_HEIGHT,
  L_PADDING,
  OPTION_GUTTER,
  OPTION_HEIGHT,
} from './constants'

type Candidate = { id: string; name: string; color: string }

export interface Props extends HTMLAttributes<HTMLDivElement> {
  prefs: string[][]
  onVoteDrop(element: Candidate, newRank: number): void
  showHovers?: boolean
  width: number
  candidates: Candidate[]
}

// const L_PADDING = 40
// const B_HEIGHT = 45
// const OPTION_HEIGHT = 40
// const B_GUTTER = 10
// const JIGGLE = 6
// const GUTTER = -10

const prefs2rankDict = (prefs: string[][]) =>
  prefs.reduce(
    (acc, idxs, rank) => ({
      ...acc,
      ...idxs.reduce(
        (ac, id) => ({ ...ac, [id]: rank }),
        {} as { [idx: string]: number },
      ),
    }),
    {} as { [idx: string]: number },
  )

const allRanks = (prefs: string[][], candidates: { id: string }[]) =>
  [
    ...prefs,
    _.difference(
      candidates.map((c) => c.id),
      prefs.flat(),
    ),
  ].filter((r) => r.length)

const Ranker: React.FC<Props> = ({
  onVoteDrop,
  prefs,
  showHovers,
  candidates,
  width: totalWidth,
  ...props
}) => {
  const ROW_WIDTH = totalWidth - L_PADDING
  const ROW_WIDTH_EXTRA = ROW_WIDTH + OPTION_GUTTER
  const ar = useMemo(() => allRanks(prefs, candidates), [candidates, prefs])
  const rankDict = prefs2rankDict(ar)
  const fn = useCallback(
    (y: number, x: number, nE: number) => ({
      x: x * (ROW_WIDTH_EXTRA / nE) + OPTION_GUTTER / 2,
      y:
        (y * 2 + 1) * (B_HEIGHT + B_GUTTER) +
        // 2 +
        (x % 2) * (B_HEIGHT - OPTION_HEIGHT),
      width: ROW_WIDTH_EXTRA / nE - OPTION_GUTTER,
      scale: 1,
      zIndex: 1 + x,
      shadow: 1,
      immediate: (n: string) => n === 'zIndex',
    }),
    [ROW_WIDTH_EXTRA],
  )
  const [nextB, setNextB] = useState<number | null>(null)
  const prefRef = useRef(rankDict)
  const [springs, setSprings] = useSprings(
    candidates.length,
    (idx) => {
      const cid = candidates[idx].id
      const y = rankDict[cid]
      const x = ar[y].indexOf(cid)
      const nE = ar[y].length
      return fn(y, x, nE)
    },
    [],
  )

  useEffect(() => {
    prefRef.current = rankDict
    setSprings.start((index: number) => {
      const cid = candidates[index].id
      const y = rankDict[cid]
      const x = ar[y].indexOf(cid)
      const nE = ar[y].length
      return fn(y, x, nE)
    })
  }, [candidates, prefs, fn, setSprings, rankDict, ar])

  type Args = [
    i: number,
    onDropi: (element: Candidate, newRank: number) => void,
    size: number,
  ]
  const bind: (...args: Args) => ReactDOMAttributes = useDrag(
    ({ args, down, movement: [, y] }) => {
      const [originalIndex, onDropi, size] = args as Args
      const candidate: Candidate | undefined = candidates[originalIndex]
      const prevRank = candidate ? prefRef.current[candidate.id] : 0
      setSprings.start((index: number) => {
        if (down && index === originalIndex)
          return {
            x: 0,
            y: (prevRank * 2 + 1) * (B_HEIGHT + B_GUTTER) + y,
            width: ROW_WIDTH,
            scale: 1.1,
            zIndex: 100,
            shadow: 15,
            immediate: (n: string) => n === 'y' || n === 'zIndex',
          }
        return {}
      })
      if (showHovers || !down) {
        const YYY = (prevRank * 2 + 1) * (B_HEIGHT + B_GUTTER) + y
        const nextIdx =
          _.clamp(
            Math.round(YYY / (B_HEIGHT + B_GUTTER) - 1),
            -1,
            size * 2 + 1,
          ) / 2
        if (!down) {
          setNextB(null)
          onDropi(candidate, nextIdx)
        } else if (showHovers && nextIdx !== nextB) {
          setNextB(nextIdx)
        }
      }
    },
  )

  const move = (candidateIdx: number, upDown: number) => {
    const candidate = candidates[candidateIdx]
    const rank = rankDict[candidate.id]
    const isAlone = Object.values(rankDict).filter((r) => r === rank).length < 2
    const dif = isAlone ? upDown : upDown / 2
    onVoteDrop(candidate, rank + dif)
  }
  const maxRank: number = ar.length

  const aloneFirst = prefs[0]?.length === 1 ? prefs[0][0] : null
  const aloneLast =
    prefs.at(-1)?.length === 1 && prefs.flat().length === candidates.length
      ? prefs.at(-1)?.[0]
      : null
  return (
    <div
      style={{
        ...props.style,
        height: (2 * maxRank + 1) * (B_HEIGHT + B_GUTTER),
        position: 'relative',
        transition: 'height 0.5s',
      }}
    >
      <div
        style={{
          position: 'relative',
          marginLeft: `${L_PADDING - OPTION_GUTTER / 2}px`,
        }}
      >
        {springs.map(({ zIndex, shadow, y, scale, width, x }, i) => (
          <animated.div
            // eslint-disable-next-line react/no-array-index-key
            // key={candidates[i].id}
            key={i}
            {...bind(i, onVoteDrop, maxRank)}
            className="candidateglob"
            tabIndex={0}
            style={{
              zIndex,
              width,
              boxShadow: shadow.to(
                (s) => `rgba(0, 0, 0, 0.15) 0px ${s}px ${2 * s}px 0px`,
              ),
              transform: to(
                [x, y, scale],
                (xx, yy, s) => `translate3d(${xx}px,${yy}px,0) scale(${s})`,
              ),
              backgroundColor: candidates[i].color,
            }}
            onKeyDown={(e) => {
              if (e.key === 'ArrowUp') move(i, -1)
              else if (e.key === 'ArrowDown') move(i, 1)
              else return
              e.preventDefault()
            }}
          >
            <div className="car">
              <CaretUpOutlined
                onMouseDown={(e) => {
                  move(i, -1)
                  e.preventDefault()
                  e.stopPropagation()
                }}
                style={{
                  cursor: 'pointer',
                  visibility:
                    aloneFirst === candidates[i].id ? 'hidden' : undefined,
                }}
              />
              <CaretDownOutlined
                onMouseDown={(e) => {
                  move(i, 1)
                  e.preventDefault()
                  e.stopPropagation()
                }}
                style={{
                  cursor: 'pointer',
                  visibility:
                    aloneLast === candidates[i].id ? 'hidden' : undefined,
                }}
              />
            </div>
            {candidates[i].name}
          </animated.div>
        ))}
      </div>
      <BucketBoxes
        bHeight={B_HEIGHT}
        bGutter={B_GUTTER}
        count={maxRank}
        hovered={nextB === null ? null : nextB * 2 + 1}
      />
      <style jsx global>{`
        .car {
          flex-direction: column;
          margin-right: 8px;
        }
        .car > span:hover {
          outline: solid 1px white;
          background: rgba(255, 255, 255, 0.3);
          border-radius: 2px;
        }
        .candidateglob {
          display: flex;
          align-items: center;
          white-space: nowrap;
          text-overflow: ellipsis;
          overflow: hidden;
          touch-action: none;
          position: absolute;
          height: ${OPTION_HEIGHT}px;
          pointer-events: auto;
          transform-origin: 50% 50% 0;
          border-radius: 5px;
          color: #fff;
          line-height: 30px;
          padding-left: 8px;
          text-transform: uppercase;
          letter-spacing: -0.0142857em;
          cursor: grab;
          user-select: none;
        }
      `}</style>
    </div>
  )
}

export default Ranker