Solid.jsでリッチなタグ入力を作ってみた
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
は与えれた JSXEventHandlerUnion
が JSX.BoundEventHandler
という型でも JSX.EventHandler
として扱えるようにラップする関数で、delegateEvent
は undefined
の可能性がある 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
が子に渡す onMouseDown
と onTouchStart
ハンドラをバインドした要素でドラッグ動作が発生した場合にそれを処理するラッパーコンポーネントです。なお mousemove
や mouseup
は document
にバインドしないとドラッグ開始した要素の外に出てしまうとイベントが発生しなくなるので 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
がついたドラッグしているタグと同じ値を持つタグをポインタの clientX
と clientY
で transform: translateX(x) translateY(y)
しています。
最初はちょっとわかりづらいですが、ドラッグ中に setValues
を呼んでしまうと並び替わったことを検知してうまくハンドリングするのが面倒なので要素が入れ替わる時は setOrder
を呼んでインデックスだけを入れ替えてそれをもとに設定される renderValues
を描画しています。ドラッグ終了時に setValues
して実際に値の変更を反映しています。
実際にこれを動かしてみるとドラッグのはじまりは「びよ〜ん」とくっついてるような感じになっていて、距離が離れると「ぷちっ」と外れて自由に動かせるようになるなんかちょっと触ってて気持ちのいい感じのする挙動になります。
Discussion