この記事はテックタッチアドベントカレンダー11日目の記事です。
テックタッチのフロントエンドエンジニアのshioriです。
エンジニアですがデザインも学びたいということで、アドベントカレンダーのアイキャッチを担当しました。アドベントカレンダーの1日ごとに窓が開いていくワクワク感を、アイキャッチで表現したいなと思いこのデザインにしました。
この記事ではクリックイベントについてお話したいと思います。
はじまり
きっかけは、こんなコンポーネントを作ろうとしていたことから始まりました。
これはAtlassianのデザインシステムにあるInline editを参考に自前で作ったものです。 https://atlaskit.atlassian.com/packages/core/inline-edit
このコンポーネントで実現したい動きは以下の通りです。
- テキストフィールドにフォーカスがあたると、右下に保存&キャンセルボタンが表示される。
- 保存ボタンを押すと保存され、保存結果のところに結果が反映される。
- キャンセルボタンを押すと元のテキスト状態に戻る。
- テキストフィールドからフォーカスが外れると、保存&キャンセルボタンは非表示になる。
上記のgif画像は完成形ですが、何も考えずに一番初めに作ったのがこちら。
保存もキャンセルも実行されません。 なぜなのか。
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ログを仕掛けて結果を確認したところ、以下のようになりました。
テキストフィールドの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> ) }
結果がこちら
保存ボタンの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 」です。お楽しみに!