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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user