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  }