diff --git a/src/validators.ts b/src/validators.ts index 359280d0..319a8a97 100644 --- a/src/validators.ts +++ b/src/validators.ts @@ -21,11 +21,17 @@ export function isKeyboardEventTriggeredByInput(ev: KeyboardEvent): boolean { } export function isHotkeyEnabledOnTag( - { target }: KeyboardEvent, + event: KeyboardEvent, enabledOnTags: readonly FormTags[] | boolean = false ): boolean { - const targetTagName = target && (target as HTMLElement).tagName + const {target, composed} = event; + let targetTagName; + if (isCustomElement(target as HTMLElement) && composed) { + targetTagName = event.composedPath()[0] && (event.composedPath()[0] as HTMLElement).tagName; + } else { + targetTagName = target && (target as HTMLElement).tagName; + } if (isReadonlyArray(enabledOnTags)) { return Boolean( targetTagName && enabledOnTags && enabledOnTags.some((tag) => tag.toLowerCase() === targetTagName.toLowerCase()) @@ -35,6 +41,13 @@ export function isHotkeyEnabledOnTag( return Boolean(targetTagName && enabledOnTags && enabledOnTags === true) } +export function isCustomElement(element: HTMLElement): boolean { + // We just do a basic check w/o any complex RegEx or validation against the list of legacy names containing a hyphen, + // as none of them is likely to be an event target, and it won't hurt anyway if we miss. + // see: https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname + return !!element.tagName && !element.tagName.startsWith("-") && element.tagName.includes("-"); +} + export function isScopeActive(activeScopes: string[], scopes?: Scopes): boolean { if (activeScopes.length === 0 && scopes) { console.warn( diff --git a/tests/useHotkeys.test.tsx b/tests/useHotkeys.test.tsx index c8d88df8..ddf8c27c 100644 --- a/tests/useHotkeys.test.tsx +++ b/tests/useHotkeys.test.tsx @@ -10,7 +10,7 @@ import { useCallback, useState, } from 'react' -import { createEvent, fireEvent, render, screen, renderHook } from '@testing-library/react' +import { createEvent, fireEvent, render, screen, renderHook, within } from '@testing-library/react' const wrapper = (initialScopes: string[]): JSXElementConstructor<{ children: ReactElement }> => @@ -491,6 +491,51 @@ test('should be disabled on form tags by default', async () => { expect(getByTestId('form-tag')).toHaveValue('A') }) +test('should be disabled on form tags inside custom elements by default', async () => { + const user = userEvent.setup() + const callback = jest.fn() + + customElements.define( + "custom-input", + class extends HTMLElement { + constructor() { + super(); + + const inputEle = document.createElement("input"); + inputEle.setAttribute("type", "text"); + inputEle.setAttribute("data-testid", "input"); + + const shadowRoot = this.attachShadow({ + mode: "open" + }); + + shadowRoot.appendChild(inputEle); + } + }, + ); + + const Component = ({ cb }: { cb: HotkeyCallback }) => { + useHotkeys('a', cb) + + // @ts-ignore + return + } + + const { getByTestId } = render() + + await user.keyboard('A') + + expect(callback).toHaveBeenCalledTimes(1) + + // @ts-ignore + await user.click(within(getByTestId('form-tag').shadowRoot).getByTestId('input')) + await user.keyboard('A') + + expect(callback).toHaveBeenCalledTimes(1) + // @ts-ignore + expect(within(getByTestId('form-tag').shadowRoot).getByTestId('input')).toHaveValue('A') +}) + test('should be enabled on given form tags', async () => { const user = userEvent.setup() const callback = jest.fn()