| /** |
| * @license |
| * Copyright 2020 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| interface ElementWithShadowRoot extends Element { |
| shadowRoot: ShadowRoot; |
| } |
| |
| /** |
| * Type guard for element with a shadowRoot. |
| */ |
| function isElementWithShadowRoot( |
| el: Element | ShadowRoot |
| ): el is ElementWithShadowRoot { |
| return 'shadowRoot' in el; |
| } |
| |
| export function isElement(node: Node): node is Element { |
| return node.nodeType === 1; |
| } |
| |
| export function isHtmlElement(node: Node): node is HTMLElement { |
| return isElement(node) && node instanceof HTMLElement; |
| } |
| |
| export function isElementTarget( |
| target: EventTarget | null | undefined |
| ): target is Element { |
| if (!target) return false; |
| return 'nodeType' in target && isElement(target as Node); |
| } |
| |
| // TODO: maybe should have a better name for this |
| function getPathFromNode(el: EventTarget) { |
| let tagName = ''; |
| let id = ''; |
| let className = ''; |
| if (el instanceof Element) { |
| tagName = el.tagName; |
| id = el.id; |
| className = el.className; |
| } |
| if ( |
| !tagName || |
| 'GR-APP' === tagName || |
| el instanceof DocumentFragment || |
| el instanceof HTMLSlotElement |
| ) { |
| return ''; |
| } |
| let path = ''; |
| if (tagName) { |
| path += tagName.toLowerCase(); |
| } |
| if (id) { |
| path += `#${id}`; |
| } |
| if (className) { |
| path += `.${className.replace(/ /g, '.')}`; |
| } |
| return path; |
| } |
| |
| /** |
| * Get computed style value. |
| */ |
| export function getComputedStyleValue(name: string, el: Element) { |
| return getComputedStyle(el).getPropertyValue(name).trim(); |
| } |
| |
| /** |
| * Query selector on a dom element. |
| * |
| * This is shadow DOM compatible, but only works when selector is within |
| * one shadow host, won't work if your selector is crossing |
| * multiple shadow hosts. |
| * |
| */ |
| export function querySelector( |
| el: Element | ShadowRoot, |
| selector: string |
| ): Element | null { |
| let nodes = [el]; |
| let result = null; |
| while (nodes.length) { |
| const node = nodes.pop(); |
| |
| // Skip if it's an invalid node. |
| if (!node || !node.querySelector) continue; |
| |
| // Try find it with native querySelector directly |
| result = node.querySelector(selector); |
| |
| if (result) { |
| break; |
| } |
| |
| // Add all nodes with shadowRoot and loop through |
| const allShadowNodes = [...node.querySelectorAll('*')] |
| .filter(isElementWithShadowRoot) |
| .map(child => child.shadowRoot); |
| nodes = nodes.concat(allShadowNodes); |
| |
| // Add shadowRoot of current node if has one |
| // as its not included in node.querySelectorAll('*') |
| if (isElementWithShadowRoot(node)) { |
| nodes.push(node.shadowRoot); |
| } |
| } |
| return result; |
| } |
| |
| /** |
| * Query selector all dom elements matching with certain selector. |
| * |
| * This is shadow DOM compatible, but only works when selector is within |
| * one shadow host, won't work if your selector is crossing |
| * multiple shadow hosts. |
| * |
| * Note: this can be very expensive, only use when have to. |
| */ |
| export function querySelectorAll( |
| el: Element | ShadowRoot, |
| selector: string |
| ): Element[] { |
| let nodes = [el]; |
| const results = new Set<Element>(); |
| while (nodes.length) { |
| const node = nodes.pop(); |
| |
| if (!node || !node.querySelectorAll) continue; |
| |
| // Try find all from regular children |
| [...node.querySelectorAll(selector)].forEach(el => results.add(el)); |
| |
| // Add all nodes with shadowRoot and loop through |
| const allShadowNodes = [...node.querySelectorAll('*')] |
| .filter(isElementWithShadowRoot) |
| .map(child => child.shadowRoot); |
| nodes = nodes.concat(allShadowNodes); |
| |
| // Add shadowRoot of current node if has one |
| // as its not included in node.querySelectorAll('*') |
| if (isElementWithShadowRoot(node)) { |
| nodes.push(node.shadowRoot); |
| } |
| } |
| return [...results]; |
| } |
| |
| export function windowLocationReload() { |
| const e = new Error(); |
| console.info(`Calling window.location.reload(): ${e.stack}`); |
| window.location.reload(); |
| } |
| |
| /** |
| * Retrieves the dom path of the current event. |
| * |
| * If the event object contains a `path` property, then use it, |
| * otherwise, construct the dom path based on the event target. |
| * |
| * domNode.onclick = e => { |
| * getEventPath(e); // eg: div.class1>p#pid.class2 |
| * } |
| */ |
| export function getEventPath<T extends MouseEvent>(e?: T) { |
| if (!e) return ''; |
| |
| let path = e.composedPath(); |
| if (!path || !path.length) { |
| path = []; |
| let el = e.target; |
| while (el) { |
| path.push(el); |
| el = (el as Node).parentNode || (el as ShadowRoot).host; |
| } |
| } |
| |
| return path.reduce<string>((domPath: string, curEl: EventTarget) => { |
| const pathForEl = getPathFromNode(curEl); |
| if (!pathForEl) return domPath; |
| return domPath ? `${pathForEl}>${domPath}` : pathForEl; |
| }, ''); |
| } |
| |
| /** |
| * Are any ancestors of the element (or the element itself) tagged with the |
| * given css class? |
| * |
| * We are walking up the DOM using `element.parentElement`, but are not crossing |
| * Shadow DOM boundaries, if there are any. |
| */ |
| export function descendedFromClass( |
| element: Element | undefined, |
| className: string, |
| stopElement?: Element |
| ) { |
| return parentWithClass(element, className, stopElement) !== undefined; |
| } |
| |
| /** |
| * Returns an ancestor of the element (or the element itself) tagged with the |
| * given css class - or undefined. |
| * |
| * We are walking up the DOM using `element.parentElement`, but are not crossing |
| * Shadow DOM boundaries, if there are any. |
| */ |
| export function parentWithClass( |
| element: Element | undefined, |
| className: string, |
| stopElement?: Element |
| ) { |
| while (element && (!stopElement || element !== stopElement)) { |
| if (element.classList.contains(className)) return element; |
| element = element.parentElement ?? undefined; |
| } |
| return undefined; |
| } |
| |
| /** |
| * Convert any string into a valid class name. |
| * |
| * For class names, naming rules: |
| * Must begin with a letter A-Z or a-z |
| * Can be followed by: letters (A-Za-z), digits (0-9), hyphens ("-"), and underscores ("_") |
| */ |
| export function strToClassName(str = '', prefix = 'generated_') { |
| return `${prefix}${str.replace(/[^a-zA-Z0-9-_]/g, '_')}`; |
| } |
| |
| // document.activeElement is not enough, because it's not getting activeElement |
| // without looking inside of shadow roots. This will find best activeElement. |
| export function findActiveElement( |
| root: Document | ShadowRoot | null, |
| ignoreDialogs?: boolean |
| ): HTMLElement | null { |
| if (root === null) { |
| return null; |
| } |
| if ( |
| ignoreDialogs && |
| root.activeElement && |
| root.activeElement.nodeName.toUpperCase().includes('DIALOG') |
| ) { |
| return null; |
| } |
| if (root.activeElement?.shadowRoot?.activeElement) { |
| return findActiveElement(root.activeElement.shadowRoot); |
| } |
| if (!root.activeElement) { |
| return null; |
| } |
| // We block some elements |
| if ('BODY' === root.activeElement.nodeName.toUpperCase()) { |
| return null; |
| } |
| return root.activeElement as HTMLElement; |
| } |
| |
| // Whether the browser is Safari. Used for polyfilling unique browser behavior. |
| export function isSafari() { |
| return ( |
| /^((?!chrome|android).)*safari/i.test(navigator.userAgent) || |
| /iPad|iPhone|iPod/.test(navigator.userAgent) |
| ); |
| } |
| |
| export function whenVisible( |
| element: Element, |
| callback: () => void, |
| marginPx = 0 |
| ) { |
| const observer = new IntersectionObserver( |
| (entries: IntersectionObserverEntry[]) => { |
| for (const entry of entries) { |
| if (entry.isIntersecting) { |
| observer.unobserve(entry.target); |
| callback(); |
| return; |
| } |
| } |
| }, |
| {rootMargin: `${marginPx}px`} |
| ); |
| observer.observe(element); |
| } |
| |
| /** |
| * For matching the `key` property of KeyboardEvents. These are known to work |
| * with Firefox, Safari and Chrome. |
| */ |
| export enum Key { |
| ENTER = 'Enter', |
| ESC = 'Escape', |
| TAB = 'Tab', |
| SPACE = ' ', |
| LEFT = 'ArrowLeft', |
| RIGHT = 'ArrowRight', |
| UP = 'ArrowUp', |
| DOWN = 'ArrowDown', |
| } |
| |
| export enum Modifier { |
| ALT_KEY, |
| CTRL_KEY, |
| META_KEY, |
| SHIFT_KEY, |
| } |
| |
| export enum ComboKey { |
| G = 'g', |
| V = 'v', |
| } |
| |
| export interface Binding { |
| key: string | Key; |
| /** Defaults to false. */ |
| docOnly?: boolean; |
| /** Defaults to not being a combo shortcut. */ |
| combo?: ComboKey; |
| /** Defaults to no modifiers. */ |
| modifiers?: Modifier[]; |
| /** Defaults to false. If true, then `event.repeat === true` is allowed. */ |
| allowRepeat?: boolean; |
| } |
| |
| const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/); |
| |
| /** |
| * For "normal" keys we do not check that the SHIFT modifier is pressed or not, |
| * because that depends on the keyboard layout. Just checking the key string is |
| * sufficient. |
| * |
| * But for some special keys it is important whether SHIFT is pressed at the |
| * same time, for example we want to distinguish Enter from Shift+Enter. |
| */ |
| function shiftMustMatch(key: string | Key) { |
| return Object.values(Key).includes(key as Key); |
| } |
| |
| /** |
| * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier. |
| * |
| * But for special chars like []/? we don't care whether the user is pressing |
| * the ALT modifier to produce the special char. For example on a German |
| * keyboard layout you have to press ALT to produce a [. |
| */ |
| function altMustMatch(key: string | Key) { |
| return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key); |
| } |
| |
| export function eventMatchesShortcut( |
| e: KeyboardEvent, |
| shortcut: Binding |
| ): boolean { |
| if (e.key !== shortcut.key) return false; |
| const modifiers = shortcut.modifiers ?? []; |
| if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false; |
| if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false; |
| if ( |
| altMustMatch(e.key) && |
| e.altKey !== modifiers.includes(Modifier.ALT_KEY) |
| ) { |
| return false; |
| } |
| if ( |
| shiftMustMatch(e.key) && |
| e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY) |
| ) { |
| return false; |
| } |
| return true; |
| } |
| |
| export interface ShortcutOptions { |
| /** |
| * Do you want to suppress events from <input> elements and such? |
| */ |
| shouldSuppress?: boolean; |
| /** |
| * Do you want to take care of calling preventDefault() and |
| * stopPropagation() yourself? Then set this option to `false`. |
| */ |
| preventDefault?: boolean; |
| } |
| |
| /** |
| * @deprecated |
| * |
| * For LitElement use the shortcut-controller. |
| */ |
| export function addShortcut( |
| element: HTMLElement, |
| shortcut: Binding, |
| listener: (e: KeyboardEvent) => void, |
| options?: ShortcutOptions |
| ) { |
| const optShouldSuppress = options?.shouldSuppress ?? false; |
| const optPreventDefault = options?.preventDefault ?? true; |
| const wrappedListener = (e: KeyboardEvent) => { |
| if (e.repeat && !shortcut.allowRepeat) return; |
| if (optShouldSuppress && shouldSuppress(e)) return; |
| if (!eventMatchesShortcut(e, shortcut)) return; |
| if (optPreventDefault) e.preventDefault(); |
| if (optPreventDefault) e.stopPropagation(); |
| listener(e); |
| }; |
| element.addEventListener('keydown', wrappedListener); |
| return () => element.removeEventListener('keydown', wrappedListener); |
| } |
| |
| export function modifierPressed(e: KeyboardEvent) { |
| return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey; |
| } |
| |
| export function shiftPressed(e: KeyboardEvent) { |
| return e.shiftKey; |
| } |
| |
| /** |
| * When you listen on keyboard events, then within Gerrit's web app you may want |
| * to avoid firing in certain common scenarios such as key strokes from <input> |
| * elements. But this can also be undesirable, for example Ctrl-Enter from |
| * <input> should trigger a save event. |
| * |
| * The shortcuts-service has a stateful method `shouldSuppress()` with |
| * reporting functionality, which delegates to here. |
| */ |
| export function shouldSuppress(e: KeyboardEvent): boolean { |
| // Note that when you listen on document, then `e.currentTarget` will be the |
| // document and `e.target` will be `<gr-app>` due to shadow dom, but by |
| // using the composedPath() you can actually find the true origin of the |
| // event. |
| const rootTarget = e.composedPath()[0]; |
| if (!isElementTarget(rootTarget)) return false; |
| const tagName = rootTarget.tagName; |
| const type = rootTarget.getAttribute('type'); |
| const editable = !!(rootTarget as HTMLElement).isContentEditable; |
| |
| if ( |
| editable || |
| // Suppress shortcuts on <input> and <textarea>, but not on |
| // checkboxes, because we want to enable workflows like 'click |
| // mark-reviewed and then press ] to go to the next file'. |
| (tagName === 'INPUT' && type !== 'checkbox') || |
| tagName === 'TEXTAREA' || |
| tagName === 'GR-TEXTAREA' || |
| (e.key === 'Enter' && |
| (tagName === 'A' || |
| tagName === 'BUTTON' || |
| tagName === 'GR-BUTTON' || |
| tagName === 'PAPER-TAB')) |
| ) { |
| return true; |
| } |
| const path: EventTarget[] = e.composedPath() ?? []; |
| for (const el of path) { |
| if (!isElementTarget(el)) continue; |
| if (el.tagName === 'DIALOG') return true; |
| } |
| return false; |
| } |
| |
| /** Returns a promise that waits for the element's height to become > 0. */ |
| export function untilRendered(el: HTMLElement) { |
| return new Promise(resolve => { |
| whenRendered(el, resolve); |
| }); |
| } |
| |
| /** Executes the given callback when the element's height is > 0. */ |
| export function whenRendered( |
| el: HTMLElement, |
| callback: (value?: unknown) => void |
| ) { |
| if (el.clientHeight > 0) { |
| callback(); |
| return; |
| } |
| const obs = new ResizeObserver(() => { |
| if (el.clientHeight > 0) { |
| callback(); |
| obs.unobserve(el); |
| } |
| }); |
| obs.observe(el); |
| } |