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
}