go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/generic_libs/components/hotkey/hotkey.tsx (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import { MobxLitElement } from '@adobe/lit-mobx'; 16 import hotkeys, { HotkeysEvent, KeyHandler } from 'hotkeys-js'; 17 import { customElement } from 'lit/decorators.js'; 18 import { makeObservable, observable } from 'mobx'; 19 import { useEffect } from 'react'; 20 import * as React from 'react'; 21 import { createRoot, Root } from 'react-dom/client'; 22 import { useLatest } from 'react-use'; 23 24 // Let individual hotkey element set the filters instead. 25 hotkeys.filter = () => true; 26 27 export type FilterFn = ( 28 keyboardEvent: KeyboardEvent, 29 hotkeysEvent: HotkeysEvent, 30 ) => boolean; 31 32 // By default, prevent hotkeys from reacting to events from input related elements 33 // enclosed in shadow DOM. 34 const DEFAULT_FILTER_FN = ( 35 keyboardEvent: KeyboardEvent, 36 _hotkeysEvent: HotkeysEvent, 37 ) => { 38 const tagName = 39 (keyboardEvent.composedPath()[0] as Partial<HTMLElement>).tagName || ''; 40 return !['INPUT', 'SELECT', 'TEXTAREA'].includes(tagName); 41 }; 42 43 export interface HotkeyProps { 44 readonly hotkey: string; 45 readonly handler: KeyHandler; 46 readonly filter?: FilterFn; 47 readonly children: React.ReactNode; 48 } 49 50 /** 51 * Register a global keydown event listener. 52 * The event listener is automatically unregistered when the component is 53 * disconnected. 54 */ 55 export function Hotkey({ hotkey, handler, filter, children }: HotkeyProps) { 56 const filterFn = filter ?? DEFAULT_FILTER_FN; 57 58 // Use a reference so we don't have to re-bind the hotkey when the filter or 59 // handler gets updated. 60 const handle = useLatest<KeyHandler>((keyboardEvent, hotkeysEvent) => { 61 if (!filterFn(keyboardEvent, hotkeysEvent)) { 62 return; 63 } 64 handler(keyboardEvent, hotkeysEvent); 65 }); 66 67 useEffect(() => { 68 hotkeys(hotkey, (...params) => handle.current(...params)); 69 return () => { 70 hotkeys.unbind(hotkey); 71 }; 72 }, [hotkey, handle]); 73 74 return <>{children}</>; 75 } 76 77 @customElement('milo-hotkey') 78 export class HotkeyElement extends MobxLitElement { 79 @observable.ref key!: string; 80 handler!: KeyHandler; 81 filter?: FilterFn; 82 83 // Ensures the function references never change even when new functions are 84 // assigned to `this.handler` or `this.filter`. 85 // This helps reducing updates. 86 private handlerFn: KeyHandler = (...params) => this.handler(...params); 87 private filterFn: FilterFn = (...params) => 88 (this.filter ?? DEFAULT_FILTER_FN)(...params); 89 90 private readonly parent: HTMLSpanElement; 91 private readonly root: Root; 92 93 constructor() { 94 super(); 95 makeObservable(this); 96 this.parent = document.createElement('span'); 97 this.root = createRoot(this.parent); 98 } 99 100 protected render() { 101 this.root.render( 102 <Hotkey hotkey={this.key} handler={this.handlerFn} filter={this.filterFn}> 103 <slot></slot> 104 </Hotkey>, 105 ); 106 return this.parent; 107 } 108 }