Fix focus not returned to SVG Element (#3704)

This PR fixes an issue where the focus is not returned to an `SVG`
element with a `tabIndex` correctly.

There are a few issues going on here:

1. We assume that the element to focus (`e.target`) is an instanceof
`HTMLElement`, but the `SVGElement` is not an instanceof `HTMLElement`.
2. By using `instanceof` we are checking against concrete classes, so if
this happen to cross certain contexts (Shadow DOM, Iframes, ...) then
the instances would be different.

To solve this, we will now:

1. Relax the types and only care about the actual attributes and methods
we are interested in. In most cases this means changing internal types
from `HTMLElement` to `Element` for example.
2. We will check whether certain properties are available in the object
to deduce the correct type from the object.

Fixes: #3660

## Test plan

Added an SVG to open a Dialog component and made sure that pressing
`escape` or clicking outside of the Dialog does restore the focus to the
SVG itself.
```tsx
<svg
  tabIndex={0}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      setIsOpen((v) => !v)
    }
  }}
  onClick={() => setIsOpen((v) => !v)}
  className="h-6 w-6 text-gray-500"
>
  <BookOpenIcon />
</svg>
```


Here is a video of that behavior:


https://github.com/user-attachments/assets/1805ca67-8bc7-4315-98a7-2490cba9230c
This commit is contained in:
Robin Malfait
2025-04-25 14:52:32 +02:00
committed by GitHub
parent 0558bdb68e
commit 30a6d51665
25 changed files with 177 additions and 105 deletions
+1
View File
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix clicking `Label` component should open `<Input type="file">` ([#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
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
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)) {
@@ -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<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
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<TTag extends ElementType = typeof DEFAULT_DISCLOSURE_TAG>(
} as StateDefinition)
let [{ disclosureState, buttonId }, dispatch] = reducerBag
let close = useEvent((focusableElement?: HTMLElement | MutableRefObject<HTMLElement | null>) => {
dispatch({ type: ActionTypes.CloseDisclosure })
let ownerDocument = getOwnerDocument(internalDisclosureRef)
if (!ownerDocument) return
if (!buttonId) return
let close = useEvent(
(focusableElement?: HTMLOrSVGElement | MutableRefObject<HTMLOrSVGElement | null>) => {
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<ContextType<typeof DisclosureAPIContext>>(() => ({ close }), [close])
@@ -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<HTMLElement>)
| (() => Iterable<Element>)
// List of containers
| MutableRefObject<Set<MutableRefObject<HTMLElement | null>>>
| MutableRefObject<Set<MutableRefObject<Element | null>>>
function resolveContainers(containers?: Containers): Set<HTMLElement> {
function resolveContainers(containers?: Containers): Set<Element> {
if (!containers) return new Set<HTMLElement>()
if (typeof containers === 'function') return new Set(containers())
let all = new Set<HTMLElement>()
let all = new Set<Element>()
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<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
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<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
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<TTag extends ElementType = typeof DEFAULT_FOCUS_TRAP_TAG>(
// 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<HTMLElement[]>(history.slice())
let localHistory = useRef(history.slice())
useWatch(
([newEnabled], [oldEnabled]) => {
@@ -418,7 +419,7 @@ function useFocusLock(
ownerDocument: Document | null
container: MutableRefObject<HTMLElement | null>
containers?: Containers
previousActiveElement: MutableRefObject<HTMLElement | null>
previousActiveElement: MutableRefObject<HTMLOrSVGElement | null>
}
) {
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<HTMLElement>, element: HTMLElement) {
function contains(containers: Set<Element>, element: Element) {
for (let container of containers) {
if (container.contains(element)) return true
}
@@ -155,7 +155,7 @@ function LabelFn<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
// 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<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
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<TTag extends ElementType = typeof DEFAULT_LABEL_TAG>(
// 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' ||
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
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)) {
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
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)) {
@@ -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<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
'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<TTag extends ElementType = typeof DEFAULT_POPOVER_TAG>(
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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, {
@@ -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)
}
@@ -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<TTag extends ElementType = typeof DEFAULT_GROUP_TAG>(
htmlFor: context.switch?.id,
onClick(event: React.MouseEvent<HTMLLabelElement>) {
if (!switchElement) return
if (event.currentTarget instanceof HTMLLabelElement) {
if (DOM.isHTMLLabelElement(event.currentTarget)) {
event.preventDefault()
}
switchElement.click()
@@ -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<ContainerMetadata> {
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<ContainerMetadata> {
//
// 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<ContainerMetadata> {
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<ContainerMetadata> {
// 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<ContainerMetadata> {
'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 `<input type=range>` 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.
//
@@ -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())
}
@@ -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<E extends MouseEvent | PointerEvent | FocusEvent | TouchEvent>(
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
})
@@ -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)
@@ -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
@@ -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<T extends React.ElementType>(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())
}
@@ -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<HTMLElement | null>)[]
portals?: MutableRefObject<HTMLElement[]>
mainTreeNode?: HTMLElement | null
defaultContainers?: (Element | null | MutableRefObject<Element | null>)[]
portals?: MutableRefObject<Element[]>
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 `<body>`
if (container === document.head) continue // Skip `<head>`
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<HTMLElement | null>(null)
let MainTreeContext = createContext<Element | null>(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<HTMLElement | null>(null)
let [mainTreeNode, setMainTreeNode] = useState<Element | null>(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 `<body>`
if (container === document.head) continue // Skip `<head>`
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
}
@@ -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<typeof useInteractions>['getReferenceProps']
getFloatingProps: ReturnType<typeof useInteractions>['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
@@ -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<KeyboardEvent>[] {
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 }),
})
@@ -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(() => {
// <span>Click me</span>
// </button>
// ```
focusableElement = focusableElement.closest(focusableSelector) as HTMLElement
focusableElement = focusableElement.closest(focusableSelector) as HTMLOrSVGElement & Element
history.unshift(focusableElement ?? e.target)
+5 -3
View File
@@ -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
}
@@ -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(() => {
+45 -6
View File
@@ -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]'
@@ -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 })
}
@@ -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
}