diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index a540376f1f..1d9d0bfb2f 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix clicking `Label` component should open `` ([#3707](https://github.com/tailwindlabs/headlessui/pull/3707)) - Ensure clicking on interactive elements inside `Label` component works ([#3709](https://github.com/tailwindlabs/headlessui/pull/3709)) +- Fix focus not returned to SVG Element ([#3704](https://github.com/tailwindlabs/headlessui/pull/3704)) ## [2.2.2] - 2025-04-17 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 3bb115f893..70f63ae3c9 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -63,6 +63,7 @@ import { history } from '../../utils/active-element-history' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' +import * as DOM from '../../utils/dom' import { match } from '../../utils/match' import { isMobile } from '../../utils/platform' import { @@ -1012,8 +1013,8 @@ function ButtonFn( } let option = e.target.closest('[role="option"]:not([data-disabled])') - if (option !== null) { - return QuickReleaseAction.Select(option as HTMLElement) + if (DOM.isHTMLElement(option)) { + return QuickReleaseAction.Select(option) } if (optionsElement?.contains(e.target)) { diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 03a6e92e09..781c8b5de9 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -35,6 +35,7 @@ import { } from '../../internal/open-closed' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' +import * as DOM from '../../utils/dom' import { match } from '../../utils/match' import { getOwnerDocument } from '../../utils/owner' import { @@ -185,7 +186,7 @@ function DisclosureFn( ref, optionalRef( (ref) => { - internalDisclosureRef.current = ref as HTMLElement | null + internalDisclosureRef.current = ref }, props.as === undefined || // @ts-expect-error The `as` prop _can_ be a Fragment @@ -202,22 +203,26 @@ function DisclosureFn( } as StateDefinition) let [{ disclosureState, buttonId }, dispatch] = reducerBag - let close = useEvent((focusableElement?: HTMLElement | MutableRefObject) => { - dispatch({ type: ActionTypes.CloseDisclosure }) - let ownerDocument = getOwnerDocument(internalDisclosureRef) - if (!ownerDocument) return - if (!buttonId) return + let close = useEvent( + (focusableElement?: HTMLOrSVGElement | MutableRefObject) => { + dispatch({ type: ActionTypes.CloseDisclosure }) + let ownerDocument = getOwnerDocument(internalDisclosureRef) + if (!ownerDocument) return + if (!buttonId) return - let restoreElement = (() => { - if (!focusableElement) return ownerDocument.getElementById(buttonId) - if (focusableElement instanceof HTMLElement) return focusableElement - if (focusableElement.current instanceof HTMLElement) return focusableElement.current + let restoreElement = (() => { + if (!focusableElement) return ownerDocument.getElementById(buttonId) + if (DOM.isHTMLorSVGElement(focusableElement)) return focusableElement + if ('current' in focusableElement && DOM.isHTMLorSVGElement(focusableElement.current)) { + return focusableElement.current + } - return ownerDocument.getElementById(buttonId) - })() + return ownerDocument.getElementById(buttonId) + })() - restoreElement?.focus() - }) + restoreElement?.focus() + } + ) let api = useMemo>(() => ({ close }), [close]) diff --git a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx index 482f2dd33f..20a084feab 100644 --- a/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx +++ b/packages/@headlessui-react/src/components/focus-trap/focus-trap.tsx @@ -21,6 +21,7 @@ import { useWatch } from '../../hooks/use-watch' import { Hidden, HiddenFeatures } from '../../internal/hidden' import type { Props } from '../../types' import { history } from '../../utils/active-element-history' +import * as DOM from '../../utils/dom' import { Focus, FocusResult, focusElement, focusIn } from '../../utils/focus-management' import { match } from '../../utils/match' import { microTask } from '../../utils/micro-task' @@ -28,18 +29,18 @@ import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from ' type Containers = // Lazy resolved containers - | (() => Iterable) + | (() => Iterable) // List of containers - | MutableRefObject>> + | MutableRefObject>> -function resolveContainers(containers?: Containers): Set { +function resolveContainers(containers?: Containers): Set { if (!containers) return new Set() if (typeof containers === 'function') return new Set(containers()) - let all = new Set() + let all = new Set() for (let container of containers.current) { - if (container.current instanceof HTMLElement) { + if (DOM.isElement(container.current)) { all.add(container.current) } } @@ -121,8 +122,8 @@ function FocusTrapFn( let direction = useTabDirection() let handleFocus = useEvent((e: ReactFocusEvent) => { - let el = container.current as HTMLElement - if (!el) return + if (!DOM.isHTMLElement(container.current)) return + let el = container.current // TODO: Cleanup once we are using real browser tests let wrapper = process.env.NODE_ENV === 'test' ? microTask : (cb: Function) => cb() @@ -163,10 +164,10 @@ function FocusTrapFn( if (!(features & FocusTrapFeatures.FocusLock)) return let allContainers = resolveContainers(containers) - if (container.current instanceof HTMLElement) allContainers.add(container.current) + if (DOM.isHTMLElement(container.current)) allContainers.add(container.current) let relatedTarget = e.relatedTarget - if (!(relatedTarget instanceof HTMLElement)) return + if (!DOM.isHTMLorSVGElement(relatedTarget)) return // Known guards, leave them alone! if (relatedTarget.dataset.headlessuiFocusGuard === 'true') { @@ -190,7 +191,7 @@ function FocusTrapFn( // It was invoked via something else (e.g.: click, programmatically, ...). Redirect to the // previous active item in the FocusTrap - else if (e.target instanceof HTMLElement) { + else if (DOM.isHTMLorSVGElement(e.target)) { focusElement(e.target) } } @@ -247,7 +248,7 @@ export let FocusTrap = Object.assign(FocusTrapRoot, { // --- function useRestoreElement(enabled: boolean = true) { - let localHistory = useRef(history.slice()) + let localHistory = useRef(history.slice()) useWatch( ([newEnabled], [oldEnabled]) => { @@ -418,7 +419,7 @@ function useFocusLock( ownerDocument: Document | null container: MutableRefObject containers?: Containers - previousActiveElement: MutableRefObject + previousActiveElement: MutableRefObject } ) { let mounted = useIsMounted() @@ -433,14 +434,14 @@ function useFocusLock( if (!mounted.current) return let allContainers = resolveContainers(containers) - if (container.current instanceof HTMLElement) allContainers.add(container.current) + if (DOM.isHTMLElement(container.current)) allContainers.add(container.current) let previous = previousActiveElement.current if (!previous) return let toElement = event.target as HTMLElement | null - if (toElement && toElement instanceof HTMLElement) { + if (DOM.isHTMLElement(toElement)) { if (!contains(allContainers, toElement)) { event.preventDefault() event.stopPropagation() @@ -457,7 +458,7 @@ function useFocusLock( ) } -function contains(containers: Set, element: HTMLElement) { +function contains(containers: Set, element: Element) { for (let container of containers) { if (container.contains(element)) return true } diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index d6a970ca04..620daed7db 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -155,7 +155,7 @@ function LabelFn( // Labels connected to 'real' controls will already click the element. But we don't know that // ahead of time. This will prevent the default click, such that only a single click happens // instead of two. Otherwise this results in a visual no-op. - if (current instanceof HTMLLabelElement) { + if (DOM.isHTMLLabelElement(current)) { e.preventDefault() } @@ -168,7 +168,7 @@ function LabelFn( context.props.onClick(e) } - if (current instanceof HTMLLabelElement) { + if (DOM.isHTMLLabelElement(current)) { let target = document.getElementById(current.htmlFor) if (target) { // Bail if the target element is disabled @@ -186,7 +186,7 @@ function LabelFn( // immediately require state changes, e.g.: Radio & Checkbox inputs need to be checked (or // unchecked). if ( - (target instanceof HTMLInputElement && + (DOM.isHTMLInputElement(target) && (target.type === 'file' || target.type === 'radio' || target.type === 'checkbox')) || target.role === 'radio' || target.role === 'checkbox' || diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 78f9c3ab34..a5a1833896 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -60,6 +60,7 @@ import type { EnsureArray, Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' +import * as DOM from '../../utils/dom' import { Focus as FocusManagementFocus, FocusableMode, @@ -376,8 +377,8 @@ function ButtonFn( } let option = e.target.closest('[role="option"]:not([data-disabled])') - if (option !== null) { - return QuickReleaseAction.Select(option as HTMLElement) + if (DOM.isHTMLElement(option)) { + return QuickReleaseAction.Select(option) } if (optionsElement?.contains(e.target)) { diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 4ad25b5c36..da1c262549 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -51,6 +51,7 @@ import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' import { Focus } from '../../utils/calculate-active-index' import { disposables } from '../../utils/disposables' +import * as DOM from '../../utils/dom' import { Focus as FocusManagementFocus, FocusableMode, @@ -241,8 +242,8 @@ function ButtonFn( } let item = e.target.closest('[role="menuitem"]:not([data-disabled])') - if (item !== null) { - return QuickReleaseAction.Select(item as HTMLElement) + if (DOM.isHTMLElement(item)) { + return QuickReleaseAction.Select(item) } if (itemsElement?.contains(e.target)) { diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index 68e11ca6bb..8876546825 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -59,6 +59,7 @@ import { } from '../../internal/open-closed' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' +import * as DOM from '../../utils/dom' import { Focus, FocusResult, @@ -358,7 +359,7 @@ function PopoverFn( 'focus', (event) => { if (event.target === window) return - if (!(event.target instanceof HTMLElement)) return + if (!DOM.isHTMLorSVGElement(event.target)) return if (popoverState !== PopoverStates.Open) return if (isFocusWithinPopoverGroup()) return if (!button) return @@ -395,8 +396,8 @@ function PopoverFn( let restoreElement = (() => { if (!focusableElement) return button - if (focusableElement instanceof HTMLElement) return focusableElement - if ('current' in focusableElement && focusableElement.current instanceof HTMLElement) + if (DOM.isHTMLElement(focusableElement)) return focusableElement + if ('current' in focusableElement && DOM.isHTMLElement(focusableElement.current)) return focusableElement.current return button @@ -679,8 +680,8 @@ function ButtonFn( let direction = useTabDirection() let handleFocus = useEvent(() => { - let el = state.panel as HTMLElement - if (!el) return + if (!DOM.isHTMLElement(state.panel)) return + let el = state.panel function run() { let result = match(direction.current, { diff --git a/packages/@headlessui-react/src/components/portal/portal.tsx b/packages/@headlessui-react/src/components/portal/portal.tsx index 8c4a6d7213..9a21fb00c6 100644 --- a/packages/@headlessui-react/src/components/portal/portal.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.tsx @@ -22,6 +22,7 @@ import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complet import { optionalRef, useSyncRefs } from '../../hooks/use-sync-refs' import { usePortalRoot } from '../../internal/portal-force-root' import type { Props } from '../../types' +import * as DOM from '../../utils/dom' import { env } from '../../utils/env' import { forwardRefWithAs, useRender, type HasDisplayName, type RefProp } from '../../utils/render' @@ -120,7 +121,7 @@ let InternalPortalFn = forwardRefWithAs(function InternalPortalFn< useOnUnmount(() => { if (!target || !element) return - if (element instanceof Node && target.contains(element)) { + if (DOM.isNode(element) && target.contains(element)) { target.removeChild(element) } diff --git a/packages/@headlessui-react/src/components/switch/switch.tsx b/packages/@headlessui-react/src/components/switch/switch.tsx index f3c9df671b..6076dc4aa4 100644 --- a/packages/@headlessui-react/src/components/switch/switch.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.tsx @@ -28,6 +28,7 @@ import { FormFields } from '../../internal/form-fields' import { useProvidedId } from '../../internal/id' import type { Props } from '../../types' import { isDisabledReactIssue7711 } from '../../utils/bugs' +import * as DOM from '../../utils/dom' import { attemptSubmit } from '../../utils/form' import { forwardRefWithAs, @@ -85,7 +86,7 @@ function GroupFn( htmlFor: context.switch?.id, onClick(event: React.MouseEvent) { if (!switchElement) return - if (event.currentTarget instanceof HTMLLabelElement) { + if (DOM.isHTMLLabelElement(event.currentTarget)) { event.preventDefault() } switchElement.click() diff --git a/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts b/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts index 3a70893e8f..19404eb299 100644 --- a/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts +++ b/packages/@headlessui-react/src/hooks/document-overflow/handle-ios-locking.ts @@ -1,4 +1,5 @@ import { disposables } from '../../utils/disposables' +import * as DOM from '../../utils/dom' import { isIOS } from '../../utils/platform' import type { ScrollLockStep } from './overflow-store' @@ -13,7 +14,7 @@ export function handleIOSLocking(): ScrollLockStep { return { before({ doc, d, meta }) { - function inAllowedContainer(el: HTMLElement) { + function inAllowedContainer(el: Element) { return meta.containers .flatMap((resolve) => resolve()) .some((container) => container.contains(el)) @@ -46,12 +47,12 @@ export function handleIOSLocking(): ScrollLockStep { // // Let's try and capture that element and store it, so that we can later scroll to it once the // Dialog closes. - let scrollToElement: HTMLElement | null = null + let scrollToElement: Element | null = null d.addEventListener( doc, 'click', (e) => { - if (!(e.target instanceof HTMLElement)) { + if (!DOM.isHTMLorSVGElement(e.target)) { return } @@ -60,8 +61,8 @@ export function handleIOSLocking(): ScrollLockStep { if (!anchor) return let { hash } = new URL(anchor.href) let el = doc.querySelector(hash) - if (el && !inAllowedContainer(el as HTMLElement)) { - scrollToElement = el as HTMLElement + if (DOM.isHTMLorSVGElement(el) && !inAllowedContainer(el)) { + scrollToElement = el } } catch (err) {} }, @@ -70,8 +71,8 @@ export function handleIOSLocking(): ScrollLockStep { // Rely on overscrollBehavior to prevent scrolling outside of the Dialog. d.addEventListener(doc, 'touchstart', (e) => { - if (e.target instanceof HTMLElement) { - if (inAllowedContainer(e.target as HTMLElement)) { + if (DOM.isHTMLorSVGElement(e.target) && DOM.hasInlineStyle(e.target)) { + if (inAllowedContainer(e.target)) { // Find the root of the allowed containers let rootContainer = e.target while ( @@ -93,14 +94,14 @@ export function handleIOSLocking(): ScrollLockStep { 'touchmove', (e) => { // Check if we are scrolling inside any of the allowed containers, if not let's cancel the event! - if (e.target instanceof HTMLElement) { + if (DOM.isHTMLorSVGElement(e.target)) { // Some inputs like `` use touch events to // allow interaction. We should not prevent this event. - if (e.target.tagName === 'INPUT') { + if (DOM.isHTMLInputElement(e.target)) { return } - if (inAllowedContainer(e.target as HTMLElement)) { + if (inAllowedContainer(e.target)) { // Even if we are in an allowed container, on iOS the main page can still scroll, we // have to make sure that we `event.preventDefault()` this event to prevent that. // diff --git a/packages/@headlessui-react/src/hooks/use-on-disappear.ts b/packages/@headlessui-react/src/hooks/use-on-disappear.ts index 924a755be8..6ac4bb3c55 100644 --- a/packages/@headlessui-react/src/hooks/use-on-disappear.ts +++ b/packages/@headlessui-react/src/hooks/use-on-disappear.ts @@ -1,5 +1,6 @@ import { useEffect, type MutableRefObject } from 'react' import { disposables } from '../utils/disposables' +import * as DOM from '../utils/dom' import { useLatestValue } from './use-latest-value' /** @@ -24,21 +25,21 @@ export function useOnDisappear( useEffect(() => { if (!enabled) return - let element = ref === null ? null : ref instanceof HTMLElement ? ref : ref.current + let element = ref === null ? null : DOM.isHTMLElement(ref) ? ref : ref.current if (!element) return let d = disposables() // Try using ResizeObserver if (typeof ResizeObserver !== 'undefined') { - let observer = new ResizeObserver(() => listenerRef.current(element)) + let observer = new ResizeObserver(() => listenerRef.current(element!)) observer.observe(element) d.add(() => observer.disconnect()) } // Try using IntersectionObserver if (typeof IntersectionObserver !== 'undefined') { - let observer = new IntersectionObserver(() => listenerRef.current(element)) + let observer = new IntersectionObserver(() => listenerRef.current(element!)) observer.observe(element) d.add(() => observer.disconnect()) } diff --git a/packages/@headlessui-react/src/hooks/use-outside-click.ts b/packages/@headlessui-react/src/hooks/use-outside-click.ts index 43d47d0e1f..415befaa32 100644 --- a/packages/@headlessui-react/src/hooks/use-outside-click.ts +++ b/packages/@headlessui-react/src/hooks/use-outside-click.ts @@ -1,4 +1,5 @@ import { useCallback, useRef } from 'react' +import * as DOM from '../utils/dom' import { FocusableMode, isFocusableElement } from '../utils/focus-management' import { isMobile } from '../utils/platform' import { useDocumentEvent } from './use-document-event' @@ -21,7 +22,10 @@ const MOVE_THRESHOLD_PX = 30 export function useOutsideClick( enabled: boolean, containers: ContainerInput | (() => ContainerInput), - cb: (event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, target: HTMLElement) => void + cb: ( + event: MouseEvent | PointerEvent | FocusEvent | TouchEvent, + target: HTMLOrSVGElement & Element + ) => void ) { let isTopLayer = useIsTopLayer(enabled, 'outside-click') let cbRef = useLatestValue(cb) @@ -29,7 +33,7 @@ export function useOutsideClick( let handleOutsideClick = useCallback( function handleOutsideClick( event: E, - resolveTarget: (event: E) => HTMLElement | null + resolveTarget: (event: E) => (HTMLOrSVGElement & Element) | null ) { // Check whether the event got prevented already. This can happen if you // use the useOutsideClick hook in both a Dialog and a Menu and the inner @@ -175,7 +179,7 @@ export function useOutsideClick( } return handleOutsideClick(event, () => { - if (event.target instanceof HTMLElement) { + if (DOM.isHTMLorSVGElement(event.target)) { return event.target } return null @@ -201,7 +205,7 @@ export function useOutsideClick( 'blur', (event) => { return handleOutsideClick(event, () => { - return window.document.activeElement instanceof HTMLIFrameElement + return DOM.isHTMLIframeElement(window.document.activeElement) ? window.document.activeElement : null }) diff --git a/packages/@headlessui-react/src/hooks/use-quick-release.ts b/packages/@headlessui-react/src/hooks/use-quick-release.ts index 2b99591998..a0d65c3f75 100644 --- a/packages/@headlessui-react/src/hooks/use-quick-release.ts +++ b/packages/@headlessui-react/src/hooks/use-quick-release.ts @@ -66,7 +66,7 @@ export function useQuickRelease( 'pointerup', (e) => { if (triggeredAtRef.current === null) return - if (!DOM.isHTMLElement(e.target)) return + if (!DOM.isHTMLorSVGElement(e.target)) return let result = action(e as PointerEventWithTarget) diff --git a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts index e8e361ba71..9021ec04f0 100644 --- a/packages/@headlessui-react/src/hooks/use-refocusable-input.ts +++ b/packages/@headlessui-react/src/hooks/use-refocusable-input.ts @@ -1,4 +1,5 @@ import { useRef } from 'react' +import * as DOM from '../utils/dom' import { useEvent } from './use-event' import { useEventListener } from './use-event-listener' @@ -18,7 +19,7 @@ export function useRefocusableInput(input: HTMLInputElement | null) { useEventListener(input, 'blur', (event) => { let target = event.target - if (!(target instanceof HTMLInputElement)) return + if (!DOM.isHTMLInputElement(target)) return info.current = { value: target.value, @@ -31,7 +32,7 @@ export function useRefocusableInput(input: HTMLInputElement | null) { // If the input is already focused, we don't need to do anything if (document.activeElement === input) return - if (!(input instanceof HTMLInputElement)) return + if (!DOM.isHTMLInputElement(input)) return if (!input.isConnected) return // Focus the input diff --git a/packages/@headlessui-react/src/hooks/use-resolved-tag.ts b/packages/@headlessui-react/src/hooks/use-resolved-tag.ts index ce090669ca..adebc40599 100644 --- a/packages/@headlessui-react/src/hooks/use-resolved-tag.ts +++ b/packages/@headlessui-react/src/hooks/use-resolved-tag.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from 'react' +import * as DOM from '../utils/dom' /** * Resolve the actual rendered tag of a DOM node. If the `tag` provided is @@ -22,7 +23,7 @@ export function useResolvedTag(tag: T) { // Tag name is already known and it's a string, no need to re-render if (tagName) return - if (ref instanceof HTMLElement) { + if (DOM.isHTMLElement(ref)) { // Tag name is not known yet, render the component to find out setResolvedTag(ref.tagName.toLowerCase()) } diff --git a/packages/@headlessui-react/src/hooks/use-root-containers.tsx b/packages/@headlessui-react/src/hooks/use-root-containers.tsx index cf4fded4d4..ecbe7e8066 100644 --- a/packages/@headlessui-react/src/hooks/use-root-containers.tsx +++ b/packages/@headlessui-react/src/hooks/use-root-containers.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, type MutableRefObject } from 'react' import { Hidden, HiddenFeatures } from '../internal/hidden' +import * as DOM from '../utils/dom' import { getOwnerDocument } from '../utils/owner' import { useEvent } from './use-event' import { useOwnerDocument } from './use-owner' @@ -11,21 +12,21 @@ export function useRootContainers({ // Reference to a node in the "main" tree, not in the portalled Dialog tree. mainTreeNode, }: { - defaultContainers?: (HTMLElement | null | MutableRefObject)[] - portals?: MutableRefObject - mainTreeNode?: HTMLElement | null + defaultContainers?: (Element | null | MutableRefObject)[] + portals?: MutableRefObject + mainTreeNode?: Element | null } = {}) { let ownerDocument = useOwnerDocument(mainTreeNode) let resolveContainers = useEvent(() => { - let containers: HTMLElement[] = [] + let containers: Element[] = [] // Resolve default containers for (let container of defaultContainers) { if (container === null) continue - if (container instanceof HTMLElement) { + if (DOM.isElement(container)) { containers.push(container) - } else if ('current' in container && container.current instanceof HTMLElement) { + } else if ('current' in container && DOM.isElement(container.current)) { containers.push(container.current) } } @@ -41,7 +42,7 @@ export function useRootContainers({ for (let container of ownerDocument?.querySelectorAll('html > *, body > *') ?? []) { if (container === document.body) continue // Skip `` if (container === document.head) continue // Skip `` - if (!(container instanceof HTMLElement)) continue // Skip non-HTMLElements + if (!DOM.isElement(container)) continue // Skip non-HTMLElements if (container.id === 'headlessui-portal-root') continue // Skip the Headless UI portal root if (mainTreeNode) { if (container.contains(mainTreeNode)) continue // Skip if it is the main app @@ -57,13 +58,13 @@ export function useRootContainers({ return { resolveContainers, - contains: useEvent((element: HTMLElement) => + contains: useEvent((element: Element) => resolveContainers().some((container) => container.contains(element)) ), } } -let MainTreeContext = createContext(null) +let MainTreeContext = createContext(null) /** * A provider for the main tree node. @@ -93,9 +94,9 @@ export function MainTreeProvider({ node, }: { children: React.ReactNode - node?: HTMLElement | null + node?: Element | null }) { - let [mainTreeNode, setMainTreeNode] = useState(null) + let [mainTreeNode, setMainTreeNode] = useState(null) // 1. Prefer the main tree node from context // 2. Prefer the provided node @@ -126,7 +127,7 @@ export function MainTreeProvider({ []) { if (container === document.body) continue // Skip `` if (container === document.head) continue // Skip `` - if (!(container instanceof HTMLElement)) continue // Skip non-HTMLElements + if (!DOM.isElement(container)) continue // Skip non-HTMLElements if (container?.contains(el)) { setMainTreeNode(container) break @@ -142,7 +143,7 @@ export function MainTreeProvider({ /** * Get the main tree node from context or fallback to the optionally provided node. */ -export function useMainTreeNode(fallbackMainTreeNode: HTMLElement | null = null) { +export function useMainTreeNode(fallbackMainTreeNode: Element | null = null) { // Prefer the main tree node from context, but fallback to the provided node. return useContext(MainTreeContext) ?? fallbackMainTreeNode } diff --git a/packages/@headlessui-react/src/internal/floating.tsx b/packages/@headlessui-react/src/internal/floating.tsx index cc611d6bf6..66f22f33a4 100644 --- a/packages/@headlessui-react/src/internal/floating.tsx +++ b/packages/@headlessui-react/src/internal/floating.tsx @@ -16,9 +16,12 @@ import { createContext, useCallback, useContext, useMemo, useRef, useState } fro import { useDisposables } from '../hooks/use-disposables' import { useEvent } from '../hooks/use-event' import { useIsoMorphicEffect } from '../hooks/use-iso-morphic-effect' +import * as DOM from '../utils/dom' type Align = 'start' | 'end' type Placement = 'top' | 'right' | 'bottom' | 'left' +type AnchorTo = `${Placement}` | `${Placement} ${Align}` +type AnchorToWithSelection = `${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}` type BaseAnchorProps = { /** @@ -39,27 +42,27 @@ type BaseAnchorProps = { export type AnchorProps = | false // Disable entirely - | (`${Placement}` | `${Placement} ${Align}`) // String value to define the placement + | AnchorTo // String value to define the placement | Partial< BaseAnchorProps & { /** * The `to` value defines which side of the trigger the panel should be placed on and its * alignment. */ - to: `${Placement}` | `${Placement} ${Align}` + to: AnchorTo } > export type AnchorPropsWithSelection = | false // Disable entirely - | (`${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}`) + | AnchorToWithSelection | Partial< BaseAnchorProps & { /** * The `to` value defines which side of the trigger the panel should be placed on and its * alignment. */ - to: `${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}` + to: AnchorToWithSelection } > @@ -77,7 +80,7 @@ let FloatingContext = createContext<{ getReferenceProps: ReturnType['getReferenceProps'] getFloatingProps: ReturnType['getFloatingProps'] slot: Partial<{ - anchor: `${Placement | 'selection'}` | `${Placement | 'selection'} ${Align}` + anchor: AnchorToWithSelection }> }>({ styles: undefined, @@ -258,7 +261,7 @@ export function FloatingProvider({ let elementAmountVisible = 0 for (let child of context.elements.floating?.childNodes ?? []) { - if (child instanceof HTMLElement) { + if (DOM.isHTMLElement(child)) { let childTop = child.offsetTop // It can be that the child is fully visible, but we also want to keep the scroll // padding into account to ensure the UI looks good. Therefore we fake that the diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 6e2c608c76..5b04ecfebd 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -1,5 +1,6 @@ import { fireEvent } from '@testing-library/react' import { act } from 'react' +import * as DOM from '../utils/dom' import { pointer } from './fake-pointer' function nextFrame(cb: Function): void { @@ -41,7 +42,7 @@ export function word(input: string): Partial[] { let element = document.activeElement - if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + if (DOM.isHTMLInputElement(element) || DOM.isHTMLTextAreaElement(element)) { fireEvent.change(element, { target: Object.assign({}, element, { value: input }), }) diff --git a/packages/@headlessui-react/src/utils/active-element-history.ts b/packages/@headlessui-react/src/utils/active-element-history.ts index 1b66e06131..8a9af9839c 100644 --- a/packages/@headlessui-react/src/utils/active-element-history.ts +++ b/packages/@headlessui-react/src/utils/active-element-history.ts @@ -1,14 +1,15 @@ import { onDocumentReady } from './document-ready' +import * as DOM from './dom' import { focusableSelector } from './focus-management' -export let history: HTMLElement[] = [] +export let history: (HTMLOrSVGElement & Element)[] = [] onDocumentReady(() => { function handle(e: Event) { - if (!(e.target instanceof HTMLElement)) return + if (!DOM.isHTMLorSVGElement(e.target)) return if (e.target === document.body) return if (history[0] === e.target) return - let focusableElement = e.target as HTMLElement + let focusableElement = e.target // Figure out the closest focusable element, this is needed in a situation // where you click on a non-focusable element inside a focusable element. @@ -20,7 +21,7 @@ onDocumentReady(() => { // Click me // // ``` - focusableElement = focusableElement.closest(focusableSelector) as HTMLElement + focusableElement = focusableElement.closest(focusableSelector) as HTMLOrSVGElement & Element history.unshift(focusableElement ?? e.target) diff --git a/packages/@headlessui-react/src/utils/bugs.ts b/packages/@headlessui-react/src/utils/bugs.ts index 712047796e..1c506af323 100644 --- a/packages/@headlessui-react/src/utils/bugs.ts +++ b/packages/@headlessui-react/src/utils/bugs.ts @@ -1,3 +1,5 @@ +import * as DOM from './dom' + // See: https://github.com/facebook/react/issues/7711 // See: https://github.com/facebook/react/pull/20612 // See: https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#concept-fe-disabled (2.) @@ -5,8 +7,8 @@ export function isDisabledReactIssue7711(element: Element): boolean { let parent = element.parentElement let legend = null - while (parent && !(parent instanceof HTMLFieldSetElement)) { - if (parent instanceof HTMLLegendElement) legend = parent + while (parent && !DOM.isHTMLFieldSetElement(parent)) { + if (DOM.isHTMLLegendElement(parent)) legend = parent parent = parent.parentElement } @@ -22,7 +24,7 @@ function isFirstLegend(element: HTMLLegendElement | null): boolean { let previous = element.previousElementSibling while (previous !== null) { - if (previous instanceof HTMLLegendElement) return false + if (DOM.isHTMLLegendElement(previous)) return false previous = previous.previousElementSibling } diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 3b404e5d5d..8b7fc012c9 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -56,7 +56,7 @@ export function disposables() { }) }, - style(node: HTMLElement, property: string, value: string) { + style(node: ElementCSSInlineStyle, property: string, value: string) { let previous = node.style.getPropertyValue(property) Object.assign(node.style, { [property]: value }) return this.add(() => { diff --git a/packages/@headlessui-react/src/utils/dom.ts b/packages/@headlessui-react/src/utils/dom.ts index 481a5803e0..aae4ef3857 100644 --- a/packages/@headlessui-react/src/utils/dom.ts +++ b/packages/@headlessui-react/src/utils/dom.ts @@ -1,7 +1,7 @@ // This file contains a bunch of utilities to verify that an element is of a // specific type. // -// Normally you can use `elemenent instanceof HTMLElement`, but if you are in +// Normally you can use `element instanceof HTMLElement`, but if you are in // different JS Context (e.g.: inside an iframe) then the `HTMLElement` will be // a different class and the check will fail. // @@ -11,13 +11,52 @@ export function isNode(element: unknown): element is Node { if (typeof element !== 'object') return false if (element === null) return false - return 'nodeType' in element && 'nodeName' in element + return 'nodeType' in element +} + +export function isElement(element: unknown): element is Element { + return isNode(element) && 'tagName' in element } export function isHTMLElement(element: unknown): element is HTMLElement { - if (typeof element !== 'object') return false - if (element === null) return false - return 'nodeName' in element + return isElement(element) && 'accessKey' in element +} + +// HTMLOrSVGElement doesn't inherit from HTMLElement or from Element. But this +// is the type that contains the `tabIndex` property. +// +// Once we know that this is an `HTMLOrSVGElement` we also know that it is an +// `Element` (that contains more information) +export function isHTMLorSVGElement(element: unknown): element is HTMLOrSVGElement & Element { + return isElement(element) && 'tabIndex' in element +} + +export function hasInlineStyle(element: unknown): element is ElementCSSInlineStyle { + return isElement(element) && 'style' in element +} + +export function isHTMLIframeElement(element: unknown): element is HTMLIFrameElement { + return isHTMLElement(element) && element.nodeName === 'IFRAME' +} + +export function isHTMLInputElement(element: unknown): element is HTMLInputElement { + return isHTMLElement(element) && element.nodeName === 'INPUT' +} + +export function isHTMLTextAreaElement(element: unknown): element is HTMLTextAreaElement { + return isHTMLElement(element) && element.nodeName === 'TEXTAREA' +} + +export function isHTMLLabelElement(element: unknown): element is HTMLLabelElement { + return isHTMLElement(element) && element.nodeName === 'LABEL' +} + +export function isHTMLFieldSetElement(element: unknown): element is HTMLFieldSetElement { + return isHTMLElement(element) && element.nodeName === 'FIELDSET' +} + +export function isHTMLLegendElement(element: unknown): element is HTMLLegendElement { + return isHTMLElement(element) && element.nodeName === 'LEGEND' } // https://html.spec.whatwg.org/#interactive-content-2 @@ -34,7 +73,7 @@ export function isHTMLElement(element: unknown): element is HTMLElement { // - textarea // - video (if the controls attribute is present) export function isInteractiveElement(element: unknown): element is Element { - if (!isHTMLElement(element)) return false + if (!isElement(element)) return false return element.matches( 'a[href],audio[controls],button,details,embed,iframe,img[usemap],input:not([type="hidden"]),label,select,textarea,video[controls]' diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index 1a39a3b5c4..bbc2043d25 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -1,5 +1,6 @@ import type { MutableRefObject } from 'react' import { disposables } from './disposables' +import * as DOM from './dom' import { match } from './match' import { getOwnerDocument } from './owner' @@ -109,7 +110,7 @@ export enum FocusableMode { } export function isFocusableElement( - element: HTMLElement, + element: HTMLOrSVGElement & Element, mode: FocusableMode = FocusableMode.Strict ) { if (element === getOwnerDocument(element)?.body) return false @@ -119,7 +120,7 @@ export function isFocusableElement( return element.matches(focusableSelector) }, [FocusableMode.Loose]() { - let next: HTMLElement | null = element + let next: Element | null = element while (next !== null) { if (next.matches(focusableSelector)) return true @@ -136,7 +137,8 @@ export function restoreFocusIfNecessary(element: HTMLElement | null) { disposables().nextFrame(() => { if ( ownerDocument && - !isFocusableElement(ownerDocument.activeElement as HTMLElement, FocusableMode.Strict) + DOM.isHTMLorSVGElement(ownerDocument.activeElement) && + !isFocusableElement(ownerDocument.activeElement, FocusableMode.Strict) ) { focusElement(element) } @@ -184,7 +186,7 @@ if (typeof window !== 'undefined' && typeof document !== 'undefined') { ) } -export function focusElement(element: HTMLElement | null) { +export function focusElement(element: HTMLOrSVGElement | null) { element?.focus({ preventScroll: true }) } diff --git a/packages/@headlessui-react/src/utils/get-text-value.ts b/packages/@headlessui-react/src/utils/get-text-value.ts index 59685d86c9..7d441a7bd0 100644 --- a/packages/@headlessui-react/src/utils/get-text-value.ts +++ b/packages/@headlessui-react/src/utils/get-text-value.ts @@ -1,3 +1,5 @@ +import * as DOM from './dom' + let emojiRegex = /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g @@ -19,7 +21,7 @@ function getTextContents(element: HTMLElement): string { // This is probably the slowest part, but if you want complete control over the text value, then // it is better to set an `aria-label` instead. let copy = element.cloneNode(true) - if (!(copy instanceof HTMLElement)) { + if (!DOM.isHTMLElement(copy)) { return currentInnerText }