Skip to content

Commit 92c0bf5

Browse files
authored
fix: account for shadow-dom in activeElement checks (#2697)
* fix(@zag-js/focus-trap): pierce shadow-dom when returning focus * fix: isActiveElement + getActiveElement fixes * fix(dom-query): use isActiveElement for scope * fix(dom-query): a web-component does not have to have a focused child element * fix(dom-query): container and child are separate elements and can have focus separately * Revert "fix(dom-query): use isActiveElement for scope" This reverts commit 4fbcc0a. * revert: use isActiveElement in createScope functions * docs(changeset): add change details * fix: use dom-query/isActiveElement in createScope * refactor: use scope.isActiveElement wherever scope is available
1 parent 743dd4a commit 92c0bf5

File tree

15 files changed

+56
-29
lines changed

15 files changed

+56
-29
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/dom-query": minor
3+
---
4+
5+
Add isActiveElement helper and consistently pierce shadow-dom in focus checks

.changeset/cuddly-parrots-stand.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@zag-js/number-input": patch
3+
"@zag-js/date-picker": patch
4+
"@zag-js/focus-trap": patch
5+
"@zag-js/tags-input": patch
6+
---
7+
8+
Pierce shadow-dom in focus checks

.changeset/shy-snails-cross.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@zag-js/dom-query": patch
3+
---
4+
5+
getActiveElement returns activeElement rather than null for focusable web components with no focusable children

packages/core/src/scope.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { getActiveElement, getDocument } from "@zag-js/dom-query"
1+
import { getActiveElement, getDocument, isActiveElement } from "@zag-js/dom-query"
22
import type { Scope } from "./types"
33

44
export function createScope(props: Pick<Scope, "id" | "ids" | "getRootNode">) {
55
const getRootNode = () => (props.getRootNode?.() ?? document) as Document | ShadowRoot
66
const getDoc = () => getDocument(getRootNode())
77
const getWin = () => getDoc().defaultView ?? window
88
const getActiveElementFn = () => getActiveElement(getRootNode())
9-
const isActiveElement = (elem: HTMLElement | null) => elem === getActiveElementFn()
109
const getById = <T extends Element = HTMLElement>(id: string) => getRootNode().getElementById(id) as T | null
1110
return {
1211
...props,

packages/machines/date-picker/src/date-picker.connect.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,7 @@ export function connect<T extends PropTypes>(
543543
? (event) => {
544544
if (event.pointerType === "touch") return
545545
if (!cellState.selectable) return
546-
const focus = event.currentTarget.ownerDocument.activeElement !== event.currentTarget
546+
const focus = !scope.isActiveElement(event.currentTarget)
547547
if (hoveredValue && isEqualDay(value, hoveredValue)) return
548548
send({ type: "CELL.POINTER_MOVE", cell: "day", value, focus })
549549
}
@@ -594,7 +594,7 @@ export function connect<T extends PropTypes>(
594594
? (event) => {
595595
if (event.pointerType === "touch") return
596596
if (!cellState.selectable) return
597-
const focus = event.currentTarget.ownerDocument.activeElement !== event.currentTarget
597+
const focus = !scope.isActiveElement(event.currentTarget)
598598
if (hoveredValue && cellState.value && isEqualDay(cellState.value, hoveredValue)) return
599599
send({ type: "CELL.POINTER_MOVE", cell: "month", value: cellState.value, focus })
600600
}

packages/machines/number-input/src/cursor.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { Scope } from "@zag-js/core"
2+
13
interface Selection {
24
start?: number | undefined
35
end?: number | undefined
@@ -6,8 +8,8 @@ interface Selection {
68
afterTxt?: string | undefined
79
}
810

9-
export function recordCursor(inputEl: HTMLInputElement | null): Selection | undefined {
10-
if (!inputEl || inputEl.ownerDocument.activeElement !== inputEl) return
11+
export function recordCursor(inputEl: HTMLInputElement | null, scope: Scope): Selection | undefined {
12+
if (!inputEl || !scope.isActiveElement(inputEl)) return
1113
try {
1214
const { selectionStart: start, selectionEnd: end, value } = inputEl
1315
const beforeTxt = value.substring(0, start!)
@@ -22,8 +24,8 @@ export function recordCursor(inputEl: HTMLInputElement | null): Selection | unde
2224
} catch {}
2325
}
2426

25-
export function restoreCursor(inputEl: HTMLInputElement | null, selection: Selection | undefined) {
26-
if (!inputEl || inputEl.ownerDocument.activeElement !== inputEl) return
27+
export function restoreCursor(inputEl: HTMLInputElement | null, selection: Selection | undefined, scope: Scope) {
28+
if (!inputEl || !scope.isActiveElement(inputEl)) return
2729

2830
if (!selection) {
2931
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length)

packages/machines/number-input/src/number-input.machine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -432,10 +432,10 @@ export const machine = createMachine({
432432
syncInputElement({ context, event, computed, scope }) {
433433
const value = event.type.endsWith("CHANGE") ? context.get("value") : computed("formattedValue")
434434
const inputEl = dom.getInputEl(scope)
435-
const sel = recordCursor(inputEl)
435+
const sel = recordCursor(inputEl, scope)
436436
raf(() => {
437437
setElementValue(inputEl, value)
438-
restoreCursor(inputEl, sel)
438+
restoreCursor(inputEl, sel, scope)
439439
})
440440
},
441441
setFormattedValue({ context, computed }) {

packages/machines/tags-input/src/tags-input.dom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const getNextEl = (ctx: Scope, id: string) => nextById(getTagElements(ctx
3131

3232
export const getTagElAtIndex = (ctx: Scope, index: number) => getTagElements(ctx)[index]
3333
export const getIndexOfId = (ctx: Scope, id: string) => indexOfId(getTagElements(ctx), id)
34-
export const isInputFocused = (ctx: Scope) => ctx.getDoc().activeElement === getInputEl(ctx)
34+
export const isInputFocused = (ctx: Scope) => ctx.isActiveElement(getInputEl(ctx))
3535

3636
export const getTagValue = (ctx: Scope, id: string | null) => {
3737
if (!id) return null

packages/utilities/dom-query/src/initial-focus.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isActiveElement } from "./node"
12
import { getTabbableEdges, getTabbables } from "./tabbable"
23

34
export interface InitialFocusOptions {
@@ -30,10 +31,9 @@ export function isValidTabEvent(event: Pick<KeyboardEvent, "shiftKey" | "current
3031
if (!container) return false
3132

3233
const [firstTabbable, lastTabbable] = getTabbableEdges(container)
33-
const doc = container.ownerDocument || document
3434

35-
if (doc.activeElement === firstTabbable && event.shiftKey) return false
36-
if (doc.activeElement === lastTabbable && !event.shiftKey) return false
35+
if (isActiveElement(firstTabbable) && event.shiftKey) return false
36+
if (isActiveElement(lastTabbable) && !event.shiftKey) return false
3737
if (!firstTabbable && !lastTabbable) return false
3838

3939
return true

packages/utilities/dom-query/src/node.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ export const isElementVisible = (el: Node) => {
3737
return el.offsetWidth > 0 || el.offsetHeight > 0 || el.getClientRects().length > 0
3838
}
3939

40+
export function isActiveElement(element: Element | null | undefined): boolean {
41+
if (!element) return false
42+
const rootNode = element.getRootNode() as Document | ShadowRoot
43+
44+
return getActiveElement(rootNode) === element
45+
}
46+
4047
const TEXTAREA_SELECT_REGEX = /(textarea|select)/
4148

4249
export function isEditableElement(el: HTMLElement | EventTarget | null) {
@@ -94,7 +101,7 @@ export function getActiveElement(rootNode: Document | ShadowRoot): HTMLElement |
94101
let activeElement = rootNode.activeElement as HTMLElement | null
95102
while (activeElement?.shadowRoot) {
96103
const el = activeElement.shadowRoot.activeElement as HTMLElement | null
97-
if (el === activeElement) break
104+
if (!el || el === activeElement) break
98105
else activeElement = el
99106
}
100107
return activeElement

0 commit comments

Comments
 (0)