Techtouch Developers Blog

テックタッチ株式会社の開発チームによるテックブログです

マウスのクリックイベントについて語る

adventCalendar-day11

この記事はテックタッチアドベントカレンダー11日目の記事です。

テックタッチのフロントエンドエンジニアのshioriです。
エンジニアですがデザインも学びたいということで、アドベントカレンダーのアイキャッチを担当しました。アドベントカレンダーの1日ごとに窓が開いていくワクワク感を、アイキャッチで表現したいなと思いこのデザインにしました。

この記事ではクリックイベントについてお話したいと思います。

はじまり

きっかけは、こんなコンポーネントを作ろうとしていたことから始まりました。

adventCalendar-day11-inlineEdit

これはAtlassianのデザインシステムにあるInline editを参考に自前で作ったものです。 https://atlaskit.atlassian.com/packages/core/inline-edit

このコンポーネントで実現したい動きは以下の通りです。

  • テキストフィールドにフォーカスがあたると、右下に保存&キャンセルボタンが表示される。
  • 保存ボタンを押すと保存され、保存結果のところに結果が反映される。
  • キャンセルボタンを押すと元のテキスト状態に戻る。
  • テキストフィールドからフォーカスが外れると、保存&キャンセルボタンは非表示になる。

上記のgif画像は完成形ですが、何も考えずに一番初めに作ったのがこちら。

保存もキャンセルも実行されません。 なぜなのか。

adventCalendar-day11-first

export const inlineEdit: React.FC = () => {
  const [isFocused, setFocused] = React.useState(false)
  const [initialValue, setInitialValue] = React.useState<string>('test')
  const [value, setValue] = React.useState<string>(initialValue)

  const onSave = () => {
    setInitialValue(value)
    setFocused(false)
  }
  const onCancel = () => {
    setValue(initialValue)
    setFocused(false)
  }

  return (
    <Container>
      <StyledInput
        value={value}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          setFocused(false)
        }}
        onChange={(event) => {
          setValue(event.target.value)
        }}
      />
      {isFocused && (
        <IconButtonsContainer>
          <SaveButton onClick={() => onSave()}>save</SaveButton>
          <CancelButton onClick={() => onCancel()}>cancel</CancelButton>
        </IconButtonsContainer>
      )}
      <Result>保存結果:{initialValue}</Result>
    </Container>
  )
}


console.logでマウスイベントのログを確認すると

コンポーネントのonFocue、onBlur、onClickにconsoleログを仕掛けて結果を確認したところ、以下のようになりました。 adventCalendar-day11-log

テキストフィールドのonBlurが先に実行されるため、ボタンが非表示になりonClickが実行されない。 だから何も起きなかったんですね。 ではどうすれば、テキストフィールドのフォーカスが外れる前にボタンが押せるのでしょうか。

マウスイベントの順番

まずはクリックイベントについて詳しく見ていきます。
マウスをクリックするという単純な操作でも、内部ではmousedown, mouseup, clickという3つのイベントが発生しています。

mousedown
要素上でマウスボタンが押された
mouseup
要素上でマウスボタンを離した
click
要素上でマウスのボタンが押されて離した

mousedown/mouseupとclickは似ているように感じますが、トリガされるタイミングが異なります。

mousedownは文字通りマウスボタンが押されたタイミングでトリガされるので、3つのイベントの中で最も早いのがmousedownです。 そしてマウスボタンを離したタイミングでトリガされるのが、mouseup と click です。 mouseupはclickの実行前に完了します。 つまり、mousedown → mouseup → clickという順番でイベントが呼び出されます。

今回のコンポーネントでは、テキストフィールドからフォーカスが外れるとボタンが非表示になるわけですが、一体テキストフィールドのblurイベントはどのタイミングで発生するのかログで確認してみましょう。

import React from 'react'
import styled from 'styled-components'

export const inlineEdit: React.FC = () => {
  const [isFocused, setFocused] = React.useState(false)
  const [initialValue, setInitialValue] = React.useState<string>('test')
  const [value, setValue] = React.useState<string>(initialValue)

  const onSave = () => {
    console.log('saveボタンのonClick()')
    setInitialValue(value)
    setFocused(false)
  }
  const onCancel = () => {
    console.log('cancelボタンのonClick()')
    setValue(initialValue)
    setFocused(false)
  }

  return (
    <Container>
      <StyledInput
        value={value}
        onFocus={() => {
          console.log('テキストフィールドのonFocus()')
          setFocused(true)
        }}
        onBlur={() => {
          console.log('テキストフィールドのonBlur()')
          setFocused(false)
        }}
        onChange={(event) => {
          console.log('テキストフィールドのonChange()')
          setValue(event.target.value)
        }}
      />
      {isFocused && (
        <IconButtonsContainer>
          <SaveButton
            onMouseDown={() => console.log('saveボタンのonMouseDown')}
            onMouseUp={() => console.log('saveボタンのonMouseUp')}
            onClick={() => onSave()}
          >
            save
          </SaveButton>
          <CancelButton
            onMouseDown={() => console.log('cancelボタンのonMouseDown')}
            onMouseUp={() => console.log('cancelボタンのonMouseUp')}
            onClick={() => onCancel()}
          >
            cancel
          </CancelButton>
        </IconButtonsContainer>
      )}
      <Result>保存結果:{initialValue}</Result>
    </Container>
  )
}

結果がこちら

adventCalendar-day11-log2

保存ボタンのmousedown → blur という順番になっていますね。 つまり、ボタンのmousedownによってテキストフィールドのblurイベントが発生するということが分かりました。 冷静に考えてみれば当然の動きですね。

なので保存やキャンセル処理をmousedownで実装すると動くわけですが、mousedownしただけで保存されるのは体感としてはちょっと違和感があります。 理想はボタンを離した後に処理してほしい。 ということで、mousedownでボタンを押したフラグ(hasClicked)を立て、onBlurでフラグを確認してフラグが立っていればボタンを表示させ続けるようにします。

return (
    <Container>
      <StyledInput
        value={value}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          if (!hasClicked) {
            setFocused(false)
          }
        }}
        onChange={(event) => {
          setValue(event.target.value)
        }}
      />
      {isFocused && (
        <IconButtonsContainer>
          <SaveButton
            onMouseDown={() => {
              hasClicked.current = true
            }}
            onClick={() => {
              setInitialValue(value)
              setFocused(false)
            }}
          >
            save
          </SaveButton>
          <CancelButton
            onMouseDown={() => {
              hasClicked.current = true
            }}
            onClick={() => {
              setValue(initialValue)
              setFocused(false)
            }}
          >
            cancel
          </CancelButton>
        </IconButtonsContainer>
      )}
      <Result>保存結果:{initialValue}</Result>
    </Container>
  )
}

これでonClickの処理が実行されるようになりました。 これで完成!と言いたいところですが、さらに完成度を上げるにはもうひと手間加えたほうが良い部分があります。

要素外でマウスアップは検知されない

クリックイベントは対象要素上で操作が行われた場合に発生します。 言い換えれば、対象要素外で操作した場合はイベントが発生しないわけです。

もし、保存/キャンセルボタンを押したもののマウスを移動させ、保存/キャンセルボタンから離れた場所でマウスボタンを離した場合、上記のコードではhasClickedがtrueのまま残ってしまいます。 そのため、テキストフィールド外をクリックしても保存/キャンセルボタンが非表示になりません。

こういったケースを救うために、mousedown時にaddEventListenerでmouseupイベントを追い、mouseupを検知したタイミングでhasClickedをfalseにする処理を追加します。

const onMouseDown = () => {
    hasClicked.current = true
    const listener = () => {
      hasClicked.current = false
      document.removeEventListener('mouseup', listener, true)
    }
    document.addEventListener('mouseup', listener, true)
  }

最終的にこのようなコードになりました。

return (
    <Container>
      <StyledInput
        value={value}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          if (!hasClicked.current) {
            setFocused(false)
          }
        }}
        onChange={(event) => {
          setValue(event.target.value)
        }}
      />
      {isFocused && (
        <IconButtonsContainer>
          <SaveButton
            onMouseDown={() => onMouseDown()}
            onClick={() => onSave()}
          >
            save
          </SaveButton>
          <CancelButton
            onMouseDown={() => onMouseDown()}
            onClick={() => onCancel()}
          >
            cancel
          </CancelButton>
        </IconButtonsContainer>
      )}
      <Result>保存結果:{initialValue}</Result>
    </Container>
  )


おわり

ひょんなことからクリックイベントについて詳しく見ていく機会ができました。 今回はtabキーで要素を移動するケースは考慮から外していますが、こだわるならこの辺もケアしてあげたほうが良いですね。

次は taisaさん による「 GORM v2 で Prometheus 連携する - Techtouch Developers Blog 」です。お楽しみに!

参考文献

ja.javascript.info