🏷️

Solid.jsでリッチなタグ入力を作ってみた

2022/12/25に公開

LAPRAS Advent Calendar 2022 24日目の記事担当のことねです。

まえがき

私は「CharaXiv」というTRPG(テーブルトークRPG)のキャラクター情報を管理するツールを個人で開発・運営しています。「シンプルで直感的」をモットーに自分なりにかなり洗練されたUIが実装できていると感じています。

現在の実装に概ね満足しているのですが、一つだけ不満な要素があります。それが「タグ入力」です。厳密には「一度入力したタグを並び替えることができない」ことが非常に不満です。なのでSolid.jsを使って書き直すついでにリッチなタグ入力UIコンポーネントを作ってみました。

依存ライブラリ

CharaXiv開発に使うライブラリがいくつか入っています。

  • Solid.js
  • Tailwind.css
  • Fontawesome Free
  • clsx

ファイル構造

  • components
    • Drag.tsx
    • InputBase.tsx
    • TagInput.tsx
    • utils.ts
  • context
    • ColorScheme.ts
  • hooks
    • createLocalStorage.ts
    • createThrottle.ts

フック

TagInputの実装のために2つのフックを実装しています。

createLocalStorage

import { createEffect, createSignal, Signal } from 'solid-js'

export const createLocalStorage = <T>(key: string, init: T): Signal<T> => {
  const stored = localStorage.getItem(key)
  const [value, setValue] = createSignal(
    stored === null ? init : (JSON.parse(stored) as T),
  )
  createEffect(() => localStorage.setItem(key, JSON.stringify(value())))
  return [value, setValue]
}

かなりシンプルな実装です。呼び出し時にローカルストレージ内にキーに対応する値があった場合はその値を、なければ渡された値を初期値としてシグナルを作成してシグナルの値が更新されるたびにローカルストレージに変更を書き込んでいます。

createThrottle

import { Accessor, createSignal, onCleanup } from 'solid-js'

export const createThrottle = <T>(
  source: Accessor<T>,
  wait: number,
): Accessor<T> => {
  const [signal, setSignal] = createSignal(source())
  const handle = setInterval(() => setSignal(() => source()), wait)
  onCleanup(() => clearInterval(handle))
  return signal
}

以前の記事の内容の実装を簡略化しました。source の初期値でシグナルを作り、wait 間隔でシグナルをセットします。wait がシグナルのケースを想定していないのでシンプルにインターバルのハンドルは定数にアサインしてクリーンナップ時にインターバルを破壊します。

コンテキスト

ダークモードを実装したかったのでコンテキストを作っています。

ColorScheme

import { Accessor, createRoot, onCleanup, onMount } from 'solid-js'
import { createLocalStorage } from '../hooks/createLocalStorage'

const COLOR_SCHEME_QUERY = '(prefers-color-scheme: dark)'

const createColorScheme = (): [Accessor<boolean>, () => void] => {
  const [isDark, setIsDark] = createLocalStorage(
    'theme',
    window.matchMedia(COLOR_SCHEME_QUERY).matches,
  )

  const toggleIsDark = () => setIsDark((prev) => !prev)

  const onChange = () =>
    setIsDark(window.matchMedia(COLOR_SCHEME_QUERY).matches)

  onMount(() => {
    window.matchMedia(COLOR_SCHEME_QUERY).addEventListener('change', onChange)
  })

  onCleanup(() => {
    window
      .matchMedia(COLOR_SCHEME_QUERY)
      .removeEventListener('change', onChange)
  })

  return [isDark, toggleIsDark]
}

export const [isDark, toggleIsDark] = createRoot(createColorScheme)

Solid.js ではシグナルがそのままコンテキストとして機能します。ただし Solid.js が関知しない場所でシグナルを作ってしまうとクリーンナップ時に破壊されない可能性があるのでコンテキストとして露出したいものを返すコンストラクタ的な関数を作って Solid.js の createRoot に渡して Solid.js が関知する形で実体化します。中身自体は平凡にマッチメディアのハンドリングをしているだけで、トグルメソッドを使ってもOSのダークモード切替を使ってもきちんと切り替わるようになっています。状態はローカルストレージに保存しているのでウィンドウを閉じて戻ってきてもモードが維持されます。

コンポーネント

utils

import { Accessor, JSX } from 'solid-js'

export const delegateJSXEvent =
  <T extends HTMLElement, E extends Event>(
    accessor: Accessor<JSX.EventHandlerUnion<T, E> | undefined>,
  ): JSX.EventHandler<T, E> =>
  (event) => {
    const handler = accessor()
    if (handler) {
      if (typeof handler === 'function') {
        handler(event)
      } else {
        handler[0](handler[1], event)
      }
    }
  }

export type EventHandler<E extends Event> = (ev: E) => void

export const delegateEvent =
  <E extends Event>(
    accessor: Accessor<EventHandler<E> | undefined>,
  ): EventHandler<E> =>
  (event) => {
    const handler = accessor()
    if (handler) handler(event)
  }

Solid.js のイベントハンドラを少し扱いやすくするためのメソッドを提供しています。delegateJSXEvent は与えれた JSXEventHandlerUnionJSX.BoundEventHandler という型でも JSX.EventHandler として扱えるようにラップする関数で、delegateEventundefined の可能性がある EventHandler が定義されている時にだけ実行する EventHandler として扱えるようにラップする関数です。

Drag

import { createSignal, JSX, onCleanup, onMount } from 'solid-js'
import { delegateEvent, delegateJSXEvent } from './utils'

export type DragChildProps<T extends HTMLElement> = {
  onMouseDown: JSX.EventHandlerUnion<T, MouseEvent>
  onTouchStart: JSX.EventHandlerUnion<T, TouchEvent>
}

export type PointerEvent = MouseEvent | TouchEvent

export const getEventCoords = (event: PointerEvent) => {
  if ('touches' in event) {
    const { clientX, clientY, pageX, pageY, screenX, screenY } =
      event.touches[0]
    return { clientX, clientY, pageX, pageY, screenX, screenY }
  } else {
    const { clientX, clientY, pageX, pageY, screenX, screenY } = event
    return { clientX, clientY, pageX, pageY, screenX, screenY }
  }
}

export type DragProps<T extends HTMLElement, U extends JSX.Element> = {
  disabled?: boolean
  onDragStart?: JSX.EventHandlerUnion<T, PointerEvent>
  onDragMove?: (event: PointerEvent) => void
  onDragEnd?: (event: PointerEvent) => void
  children: (props: DragChildProps<T>) => U
}

export const Drag = <T extends HTMLElement, U extends JSX.Element>(
  props: DragProps<T, U>,
) => {
  const disabled = () => props.disabled ?? false
  const [dragging, setDragging] = createSignal(false)

  const onDragStart = delegateJSXEvent(() => props.onDragStart)
  const onDragMove = delegateEvent(() => props.onDragMove)
  const onDragEnd = delegateEvent(() => props.onDragEnd)

  const onMouseDown: JSX.EventHandler<T, MouseEvent> = (event) => {
    if (!disabled()) {
      event.preventDefault()
      setDragging(true)
      onDragStart(event)
    }
  }

  const onTouchStart: JSX.EventHandler<T, TouchEvent> = (event) => {
    if (!disabled()) {
      if (event.touches.length === 1) {
        event.preventDefault()
        setDragging(true)
        onDragStart(event)
      }
    }
  }

  const handleMouseMove = (event: MouseEvent) => {
    if (!disabled() && dragging()) {
      event.preventDefault()
      onDragMove(event)
    }
  }

  const handleTouchMove = (event: TouchEvent) => {
    if (!disabled() && dragging() && event.touches.length === 1) {
      event.preventDefault()
      onDragMove(event)
    }
  }

  const handleRelease = (event: PointerEvent) => {
    if (!disabled() && dragging()) {
      event.preventDefault()
      setDragging(false)
      onDragEnd(event)
    }
  }

  onMount(() => {
    document.addEventListener('mousemove', handleMouseMove)
    document.addEventListener('mouseup', handleRelease)
    document.addEventListener('touchmove', handleTouchMove)
    document.addEventListener('touchend', handleRelease)
    document.addEventListener('touchcancel', handleRelease)
  })

  onCleanup(() => {
    document.removeEventListener('mousemove', handleMouseMove)
    document.removeEventListener('mouseup', handleRelease)
    document.removeEventListener('touchmove', handleTouchMove)
    document.removeEventListener('touchend', handleRelease)
    document.removeEventListener('touchcancel', handleRelease)
  })

  return props.children({ onMouseDown, onTouchStart })
}

この辺でようやく Solid.js っぽくなってきます。Drag が子に渡す onMouseDownonTouchStart ハンドラをバインドした要素でドラッグ動作が発生した場合にそれを処理するラッパーコンポーネントです。なお mousemovemouseupdocument にバインドしないとドラッグ開始した要素の外に出てしまうとイベントが発生しなくなるので document にバインドしています。

InputBase

import { Component, ComponentProps, splitProps } from 'solid-js'
import { delegateJSXEvent } from './utils'

export type InputBaseProps = ComponentProps<'input'> & {
  dynamic?: boolean
}

const IGNORE_INPUT_TYPES = ['insertCompositionText', 'deleteCompositionText']

export const InputBase: Component<InputBaseProps> = (props) => {
  const [local, rest] = splitProps(props, ['onInput'])
  const onInputHandler = delegateJSXEvent(() => local.onInput)
  return (
    <input
      {...rest}
      onInput={(event) => {
        if (!IGNORE_INPUT_TYPES.includes(event.inputType)) onInputHandler(event)
      }}
    />
  )
}

変換時のエンターキーなどの入力を無視するようにデフォルトの input 要素の onInput イベントの挙動をオーバーライドするコンポーネントです。ここは複雑なことはしていません。

TagInput

それでは本題の TagInput です。本当は「Cmd/Ctrl+クリックで複数選択」をできるようにしたかったのですが間に合わなかったので実装されていません。このタグ入力を作る際に最低限満たすべき仕様として定義した要件は以下のとおりです。

  • 何も入力がなければ普通の input に見える。
  • 文字が入力された状態で Enter または Tab を押すとタグが増える。
  • タグは TagInput の幅に応じて自動的に折り返す。
  • input 要素は自動的にリサイズされる。
  • 文字が入力されていない状態で Backspace を押すと最後のタグが選択される。
  • 文字が入力されておらずかつタグが選択された状態で Backspace を押すと選択されたタグが削除される。
  • タグをクリックしてドラッグすると半透明なコピーを残して自在にドラッグできる。
  • ドラッグ開始から半径25px以内はカーソルの移動距離の 0.75 乗の距離しか移動しない。
    • タグが「くっついている」ように感じさせるための仕込み。
  • ドラッグしたタグを他のタグに重ねるとその位置に半透明なコピーが挿入される。
  • ドラッグを終了するとその時点の状態でタグが並び替えられる。
import clsx from 'clsx'
import {
  Component,
  createEffect,
  createSignal,
  Index,
  JSX,
  Show,
  untrack,
} from 'solid-js'
import { isDark } from '../context/ColorScheme'
import { createThrottle } from '../hooks/createThrottle'
import { Drag, PointerEvent, getEventCoords } from './Drag'
import { InputBase } from './InputBase'

const THRESHOLD = 25
const stick = (n: number) => Math.sign(n) * Math.pow(Math.abs(n), 0.75)

type Coord = {
  x: number
  y: number
}

const norm = ({ x, y }: Coord) => Math.sqrt(x * x + y * y)

export type UpdateHandler = (values: string[]) => void

type DragState = {
  index: number
  origin: Coord
  stuck: boolean
}

const seq = (n: number) => [...Array(n).keys()]

const reinsert = <T extends unknown>(array: T[], i: number, j: number) =>
  i < j
    ? [
        ...array.slice(0, i),
        ...array.slice(i + 1, j + 1),
        array[i],
        ...array.slice(j + 1),
      ]
    : j < i
    ? [
        ...array.slice(0, j),
        array[i],
        ...array.slice(j, i),
        ...array.slice(i + 1),
      ]
    : array

type TagProps = {
  delete?: () => void
  readonly: boolean
  selected?: boolean
  children: string
}

const Tag: Component<TagProps> = (props) => {
  const style = (select: boolean, dark: boolean) =>
    select === (props.selected ?? false) && dark === isDark()
  return (
    <div
      class={clsx(
        'flex flex-row rounded-sm align-center text-base leading-4 proportional-nums cursor-grab transition',
        // prettier-ignore
        clsx(
          style(false, false) && 'bg-nord-100 text-nord-1000',
          style( true, false) && 'bg-nord-150 text-nord-1000',
          style(false,  true) && 'bg-nord-900 text-nord-0',
          style( true,  true) && 'bg-nord-850 text-nord-0',
        ),
      )}
      onClick={(event) => event.stopPropagation()}
    >
      <div class={clsx(props.readonly ?? false ? 'px-1.5' : 'pl-1.5', 'py-1')}>
        <span class="inline-block ">{props.children}</span>
      </div>
      <Show when={!(props.readonly ?? false)}>
        <button
          class={clsx(
            'flex justify-center items-center h-6 rounded-sm aspect-square transition',
            // prettier-ignore
            clsx(
              style(false, false) && 'bg-nord-100 text-nord-800 hover:bg-nord-150',
              style( true, false) && 'bg-nord-150 text-nord-800 hover:bg-nord-200',
              style(false,  true) && 'bg-nord-900 text-nord-200 hover:bg-nord-850',
              style( true,  true) && 'bg-nord-850 text-nord-200 hover:bg-nord-800',
            ),
          )}
          onClick={props.delete}
        >
          <i class="fas fa-times" />
        </button>
      </Show>
    </div>
  )
}

export type TagInputProps = {
  values?: string[]
  update?: UpdateHandler
  readonly?: boolean
}

export const TagInput: Component<TagInputProps> = (props) => {
  const [values, setValues] = createSignal(props.values || [])
  const count = () => values().length
  createEffect(() => {
    const { update } = props
    if (update) update(values())
  })

  const readonly = () => props.readonly ?? false

  const [order, setOrder] = createSignal(seq(count()))
  createEffect(() => setOrder(seq(count())))

  const renderValues = () => order().map((index) => values()[index])

  const tagRefMap = new Map<number, HTMLDivElement>()
  const tagRects = () =>
    [...Array(count()).keys()].map((index) => {
      const el = tagRefMap.get(index)
      if (!el) throw new Error('ref has not been set')
      return el.getBoundingClientRect()
    })

  let drag: DragState | undefined

  const [coord, setCoord] = createSignal<Coord>()
  const throttledCoord = createThrottle(coord, 1000 / 60)

  createEffect(() => {
    const currentCoord = throttledCoord()
    if (!currentCoord || !drag) return
    const { x, y } = currentCoord
    const nextIndex = untrack(tagRects).findIndex(
      ({ top, right, bottom, left }) =>
        left <= x && x <= right && top <= y && y <= bottom,
    )
    if (nextIndex < 0) return
    setOrder(reinsert(seq(count()), drag.index, nextIndex))
  })

  const toActualIndex = (from: number) =>
    order().findIndex((index) => index === from)

  const tagStyle = (index: number) => {
    const currentCoord = throttledCoord()
    if (!currentCoord || !drag || toActualIndex(drag.index) !== index) return
    if (!drag.stuck) return { opacity: 0.5 }
    const x = stick(currentCoord.x - drag.origin.x)
    const y = stick(currentCoord.y - drag.origin.y)
    return { transform: `translateX(${x}px) translateY(${y}px)` }
  }

  const [selected, setSelected] = createSignal<number[]>([])

  const tagDragStart = (index: number) => (event: PointerEvent) => {
    const { clientX: x, clientY: y } = getEventCoords(event)
    const origin = { x, y }
    drag = { index, origin, stuck: true }
  }

  const tagDragMove = (event: PointerEvent) => {
    if (drag) {
      const { clientX: x, clientY: y } = getEventCoords(event)
      const cursor = { x, y }
      const offset = {
        x: cursor.x - drag.origin.x,
        y: cursor.y - drag.origin.y,
      }
      drag.stuck = norm(offset) < THRESHOLD && drag.stuck
      setCoord(cursor)
    }
  }

  const tagDragEnd = () => {
    setCoord()
    setValues(renderValues())
    setOrder(seq(count()))
    drag = undefined
  }

  let inputRef!: HTMLInputElement

  const [inputValue, setInputValue] = createSignal('')

  const inputKeyDown: JSX.EventHandler<HTMLInputElement, KeyboardEvent> = (
    event,
  ) => {
    const value = inputValue()
    if (
      value !== '' &&
      !values().includes(value) &&
      ['Enter', 'Tab'].includes(event.key)
    ) {
      setValues((prev) => [...prev, value])
      setInputValue('')
      event.currentTarget.value = ''
    }

    if (value === '' && event.key === 'Backspace') {
      const indices = selected()
      if (indices.length === 0) {
        setSelected([count() - 1])
      } else {
        setValues((prev) => prev.filter((_, index) => !indices.includes(index)))
        setSelected([])
      }
    } else {
      setSelected([])
    }
  }

  return (
    <>
      <div
        class={clsx(
          'flex flex-row flex-wrap items-center gap-2 p-2 w-full min-h-[34px] border border-solid rounded bg-nord-500 bg-opacity-0 hover:bg-opacity-10 cursor-text transition',
          isDark()
            ? 'border-nord-200 text-nord-0'
            : 'border-nord-800 text-nord-1000',
        )}
        onClick={() => inputRef.focus()}
      >
        <Index each={renderValues()}>
          {(value, index) => (
            <Drag
              onDragStart={tagDragStart(index)}
              onDragMove={tagDragMove}
              onDragEnd={tagDragEnd}
            >
              {(dragProps) => (
                <div
                  ref={(el) => tagRefMap.set(index, el)}
                  style={tagStyle(index)}
                  {...dragProps}
                >
                  <Tag
                    delete={() =>
                      setValues((prev) => [
                        ...prev.slice(0, index),
                        ...prev.slice(index + 1),
                      ])
                    }
                    readonly={readonly()}
                    selected={selected().includes(index)}
                  >
                    {value()}
                  </Tag>
                </div>
              )}
            </Drag>
          )}
        </Index>

        <div class="flex-grow">
          <InputBase
            ref={inputRef}
            class={clsx(
              'w-full min-w-[80px] border-none bg-nord-0 bg-opacity-0 active:outline-none focus:outline-none transition',
              isDark() ? 'caret-nord-0' : 'caret-nord-1000',
            )}
            size={1}
            spellcheck={false}
            placeholder="タグを追加"
            onInput={(event) => setInputValue(event.currentTarget.value)}
            onKeyDown={inputKeyDown}
          />
        </div>
      </div>

      <Show when={throttledCoord()} keyed>
        {(cursor) => {
          if (!drag || drag.stuck) return null
          const ref = tagRefMap.get(drag.index)
          if (!ref) return null
          const rect = ref.getBoundingClientRect()
          const x = cursor.x - rect.width / 2
          const y = cursor.y - rect.height / 2
          const style = { transform: `translateX(${x}px) translateY(${y}px)` }
          return (
            <div class="fixed top-0 left-0" style={style}>
              <Tag readonly={readonly()}>{values()[drag.index]}</Tag>
            </div>
          )
        }}
      </Show>
    </>
  )
}

ゴチャゴチャと色々書いていますがさして難しいことはしていません。少しトリッキーなことをしているとすればすべてのタグの DOMRect を取るために ref を一旦 Map に入れてから Array に変換することで Array<HTMLDivElement | undefined> ではなく Array<HTMLDivElement> で扱えるようにしています。あとは Drag 発生時のカーソルの位置をスロットリングして一定距離以上離れるまでは実際のタグ要素で transform: translateX(x) translateY(y) をかけて、離れたら fixed がついたドラッグしているタグと同じ値を持つタグをポインタの clientXclientYtransform: translateX(x) translateY(y) しています。
最初はちょっとわかりづらいですが、ドラッグ中に setValues を呼んでしまうと並び替わったことを検知してうまくハンドリングするのが面倒なので要素が入れ替わる時は setOrder を呼んでインデックスだけを入れ替えてそれをもとに設定される renderValues を描画しています。ドラッグ終了時に setValues して実際に値の変更を反映しています。

実際にこれを動かしてみるとドラッグのはじまりは「びよ〜ん」とくっついてるような感じになっていて、距離が離れると「ぷちっ」と外れて自由に動かせるようになるなんかちょっと触ってて気持ちのいい感じのする挙動になります。

Discussion