自作のアニメ視聴管理webサービス watch-duty-manager にこのようなメニューUIがある。ハンバーガーを押すとメニューが開く。これはHTMLのdetails/summaryタグで実現している。
こういうメニューはメニューの外側をクリックすると閉じるのが一般的だ。しかしdetailsタグは標準でそのような挙動をしない(まあメニューにだけ使われるものではないからね)。summary(ハンバーガー部分)をもう一度押すことで閉じることができる。外側クリックで閉じる方法を適当にググってみると
- detailsが開いているときはcssでsummaryを画面全体を覆うように巨大化させる
- JavaScriptのクリックイベントを全て拾ってなんとかする
という2つの方針が見つかった。1はなかなか賢いと思うものの、真面目に作り込もうと思うとなかなかバギーな部分もありそうで(特にレイヤー順とか)やめて2をやることにした。
みんな大好き react-use に useClickAway
というカスタムフックがある。refで参照されたHTML要素の外側がクリックされたときに指定されたコールバックを実行する。実装としては愚直に2をやっているだけ。
// https://github.com/streamich/react-use/blob/ad33f76dfff7ddb041a9ef74b80656a94affaa80/src/useClickAway.ts#L16-L22
const handler = (event) => {
const { current: el } = ref;
el && !el.contains(event.target) && savedCallback.current(event);
};
for (const eventName of events) {
on(document, eventName, handler);
}
これを使って
function Component() {
const ref = useRef<HTMLDetailsElement>(null);
useClickAway(ref, () => {
if (ref.current && ref.current.open) {
ref.current.removeAttribute("open");
}
})
return (
<details ref={ref}>
<summary>summary</summary>
details
</details>
)
}
てな感じでやれば一応動くものは作れる。作れるのだが、この作りだとdocument内のどこをクリックしても、画面内のこのコンポーネントの数だけ ref.current && ref.current.open
の評価が走る。実際のユースケースでは100個くらいの表示はあり得る。まあ実際のところ100個あろうと大したコストではなく、使用感に影響を与える16msには届かないのだが、個人開発だからそういう現実的な損得はおいといて、もっと効率的な作りを追求することにした。
この作りの何が無駄かと言えば同時に開くdetailsは1個しかないのに画面内のdetails全てに対して同じ判定関数を与えていることだ。そこでデフォルトではなにもしない関数を渡しておいて、detailsが開いたときだけuseClickAwayに渡す関数を差し替えることにした。
const noOpCallback = () => {};
const useCloseDetailsOnClickAway = () => {
const ref = useRef<HTMLDetailsElement>(null);
const closeCallback = useCallback(() => {
if (ref.current) {
ref.current.removeAttribute("open");
}
}, [ref.current]);
const [onClickAway, setOnClickAway] = useState({ f: noOpCallback });
const onToggle = useCallback<React.ReactEventHandler<HTMLDetailsElement>>(
(_) => {
if (ref.current === null) {
return;
}
if (ref.current.open) {
setOnClickAway({ f: closeCallback });
} else {
setOnClickAway({ f: noOpCallback });
}
},
[],
);
useClickAway(ref, onClickAway.f);
return { ref, onToggle };
};
function Component() {
const { ref, onToggle } = useCloseDetailsOnClickAway();
return (
<details ref={ref} onToggle={onToggle}>
<summary>summary</summary>
details
</details>
)
}
その結果できたカスタムフックがこれだ。detailsのtoggleイベントを監視して、開いたものだけuseClickAwayのコールバック関数をcloseCallbackに差し替えている。
ちなみに数字上の速度差はなかったです。たぶんReactやV8がなんとかしてるんでしょ。