Closed7

input要素のcaretも自作したい

うじまるうじまる


ターミナルみたいなinput要素を作りたい
標準のCSSだとcaretのstyleを当てれないのでJSで色々しないといけない

うじまるうじまる

色々あってElmで使いたいのでUIの制御はJSに任せたい
そこでWebComponentsを使う
利用するライブラリはlit-element

<terminal-input></terminal-input>

みたいな感じで書けるようにする

うじまるうじまる

早速つくる

import { LitElement, html, css, property } from 'lit-element'

export class TerminalInput extends LitElement {
    @property({type: String})
    value: string = ""

    constructor() {
        super()
    }

    focus() {
        this.shadowRoot?.querySelector('input')?.focus()
    }

    static get styles() {
        return css`
            :host {
                display: inline;
            }

            div {
                display: inline;
            }
        `
    }

    render() {
        return html`
            <div>
                <input
                    .value="${this.value}"
                    @input="${this.bindValue}"
                >
            </div>
        `
    }

    private bindValue(e: InputEvent) {
        this.value = (e.target as HTMLInputElement | null)?.value ?? ""
    }
}

詰まったポイント

value の扱い

@property({type: String}) value: string = "" の部分でこのCustomElementのフィールドとしての value と属性としての value が紐付けられるのだが ShadowDOM内にある inputvalue とは紐付けられないので自分でbindしてあげる必要がある。
やっている部分は bindValue メソッド

うじまるうじまる
import { LitElement, html, css, property } from 'lit-element'

export class TerminalInput extends LitElement {
    @property({type: String})
    value: string = ""
    @property({type: Number})
    selectionStart: number = 0
    @property({type: Number})
    selectionEnd: number = 0

    constructor() {
        super()
    }

    focus() {
        this.shadowRoot?.querySelector('input')?.focus()
    }

    static get styles() {
        return css`
            :host {
                display: inline;
            }

            div {
                display: inline;
            }

            input {
                position: absolute;
                opacity: 0;
            } 

            .caret {
                background: green;
                white-space: pre; 
            }
        `
    }

    render() {
        return html`
            <div>
                <input
                    .value="${this.value}"
                    @input="${this.bindValue}"
                    @keydown="${this.caretUpdate}"
                    @select="${this.caretUpdate}"
                >
                <span>
                    ${this.beforeString}<span class="caret">${this.currentChar}</span>${this.afterString}
                </span>
            </div>
        `
    }

    private caretUpdate(e: KeyboardEvent | InputEvent) {
        const input = e.target as HTMLInputElement;
        this.selectionStart = input.selectionStart ?? 0
        this.selectionEnd = input.selectionEnd ?? 0
    }

    private bindValue(e: InputEvent) {
        this.value = (e.target as HTMLInputElement | null)?.value ?? ""
        this.caretUpdate(e)
    }

    private get beforeString() {
        return this.value.slice(0, this.selectionStart)
    }

    private get currentChar() {
        return this.value.slice(this.selectionStart, this.selectionEnd) || " "
    }

    private get afterString() {
        return this.value.slice(this.selectionEnd)
    }

}

input の部分は全部自分でstyle等を整えるので実際の input 要素は KeyboardEvent を受け取る君として振る舞ってもらい普段は見えないようにしておく。
勝手にlayoutされると面倒なので position: absolute で適当なところに置いておく
カーソルの位置を保存しておかないといけないので selection(Start|End) をプロパティに作る

  • keydown
  • input
  • select

イベントのタイミングで更新するようにする

うじまるうじまる

keydownでselectionStartを取得してると矢印キーを押したときに
keydown event → caretの移動
なのでselectionStartがズレる、どうしようか

うじまるうじまる
private caretUpdate(e: KeyboardEvent | InputEvent) {
    const input = e.target as HTMLInputElement;

    if (e instanceof KeyboardEvent) {
        if ((e as KeyboardEvent).key === 'ArrowRight') {
            this.selectionStart = Math.min(this.selectionStart + 1, input.value.length)
            this.selectionEnd = Math.min(this.selectionEnd + 1, input.value.length)
        } else if ((e as KeyboardEvent).key === 'ArrowLeft') {
            this.selectionStart = Math.max(this.selectionStart - 1, 0)
            this.selectionEnd = Math.max(this.selectionEnd - 1, 0)
        }
    } else {
        this.selectionStart = input.selectionStart ?? 0
        this.selectionEnd = input.selectionEnd ?? 0
    }
}

KeyboardEvent だったら

結構ゴリ押し感がある
←を押したら今の値から -1
→を押したら今の値から +1
それ以外だったら特に反応しない

InputEvent だったら
inputの selection(Start|End) を使う

このスクラップは2020/12/02にクローズされました