diff --git a/.changeset/proxy-tab-focus-portal.md b/.changeset/proxy-tab-focus-portal.md new file mode 100644 index 0000000000..4978400cbd --- /dev/null +++ b/.changeset/proxy-tab-focus-portal.md @@ -0,0 +1,6 @@ +--- +"@zag-js/dom-query": patch +"@zag-js/popover": patch +--- + +Fix `proxyTabFocus` so tabbing out of portalled content moves to the next tabbable after the trigger, instead of looping back into the content when the trigger is last on the page. diff --git a/e2e/popover.e2e.ts b/e2e/popover.e2e.ts index 29fa7cd69b..01a0d39c89 100644 --- a/e2e/popover.e2e.ts +++ b/e2e/popover.e2e.ts @@ -95,6 +95,28 @@ test.describe("popover", () => { await I.seeContent() }) + test("[keyboard / non-modal] when trigger is last tabbable: tab out should not loop into content", async ({ + page, + }) => { + await page.goto("/popover/trigger-last") + + const trigger = page.getByTestId("popover-trigger") + const link = page.getByTestId("focusable-link") + const close = page.getByTestId("popover-close-button") + + await trigger.focus() + await page.keyboard.press("Enter") + await expect(link).toBeFocused() + + await page.keyboard.press("Tab") + await page.keyboard.press("Tab") + await expect(close).toBeFocused() + + await page.keyboard.press("Tab") + await expect(link).not.toBeFocused() + await expect(close).not.toBeFocused() + }) + test("[pointer] close the popover on click close button", async () => { await I.clickTrigger() await I.seeContent() diff --git a/examples/next-ts/pages/popover/trigger-last.tsx b/examples/next-ts/pages/popover/trigger-last.tsx new file mode 100644 index 0000000000..4a94b3a53d --- /dev/null +++ b/examples/next-ts/pages/popover/trigger-last.tsx @@ -0,0 +1,40 @@ +import * as popover from "@zag-js/popover" +import { normalizeProps, Portal, useMachine } from "@zag-js/react" +import { useId } from "react" +import { Presence } from "../../components/presence" + +export default function Page() { + const service = useMachine(popover.machine, { + id: useId(), + modal: false, + portalled: true, + }) + + const api = popover.connect(service, normalizeProps) + + return ( +
+
+ + + + + +
+ + + Focusable Link + + + + +
+
+
+
+ ) +} diff --git a/package.json b/package.json index 8efcb80bb7..07c7672408 100644 --- a/package.json +++ b/package.json @@ -72,14 +72,14 @@ ] }, "dependencies": { - "@axe-core/playwright": "4.11.3", + "@axe-core/playwright": "4.12.1", "@changesets/changelog-github": "0.7.0", "@changesets/cli": "2.31.0", "@commitlint/cli": "21.0.2", "@commitlint/config-conventional": "21.0.2", "@eslint/js": "^10.0.1", "@internationalized/date": "3.12.2", - "@playwright/test": "1.61.0", + "@playwright/test": "1.61.1", "@swc/core": "1.15.41", "@types/jsdom": "^28.0.1", "@types/node": "25.9.4", diff --git a/packages/utilities/dom-query/src/proxy-tab-focus.ts b/packages/utilities/dom-query/src/proxy-tab-focus.ts index 3a3e02d17c..baf65870eb 100644 --- a/packages/utilities/dom-query/src/proxy-tab-focus.ts +++ b/packages/utilities/dom-query/src/proxy-tab-focus.ts @@ -1,7 +1,7 @@ import { addDomEvent } from "./event" -import { isActiveElement } from "./node" +import { contains, isActiveElement } from "./node" import { raf } from "./raf" -import { getNextTabbable, getTabbableEdges, type GetShadowRootOption } from "./tabbable" +import { getTabbableEdges, getTabbables, type GetShadowRootOption } from "./tabbable" import type { MaybeElement, MaybeElementOrFn } from "./types" export interface ProxyTabFocusOptions { @@ -12,44 +12,66 @@ export interface ProxyTabFocusOptions { getShadowRoot?: GetShadowRootOption | undefined } -function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOptions = {}) { +function resolveElement(value: MaybeElementOrFn | undefined): MaybeElement { + if (value == null) return null + return typeof value === "function" ? value() : value +} + +/** Next tabbable after `trigger`, skipping anything inside `container` (portalled content). */ +function getNextTabbableAfterTrigger( + container: HTMLElement, + trigger: MaybeElement, + getShadowRoot: GetShadowRootOption, +) { + if (!trigger) return null + const tabbables = getTabbables(container.ownerDocument.body, { getShadowRoot }) + const triggerIndex = tabbables.indexOf(trigger) + if (triggerIndex === -1) return null + + for (let i = triggerIndex + 1; i < tabbables.length; i++) { + const el = tabbables[i] + if (!contains(container, el)) return el + } + return null +} + +function proxyTabFocusImpl(container: MaybeElementOrFn, options: ProxyTabFocusOptions = {}) { const { triggerElement, onFocus, onFocusEnter, getShadowRoot } = options - const doc = container?.ownerDocument || document - const body = doc.body + const initial = resolveElement(container) ?? resolveElement(triggerElement) + const doc = initial?.ownerDocument || document function onKeyDown(event: KeyboardEvent) { if (event.key !== "Tab") return - let elementToFocus: MaybeElement | undefined = null - - // get all tabbable elements within the container - const [firstTabbable, lastTabbable] = getTabbableEdges(container, { includeContainer: true, getShadowRoot }) - const nextTabbableAfterTrigger = getNextTabbable(body, { current: triggerElement, getShadowRoot }) + // Resolve per keypress so late-mounted / portalled nodes stay correct + const content = resolveElement(container) + const trigger = resolveElement(triggerElement) + if (!content) return + const [firstTabbable, lastTabbable] = getTabbableEdges(content, { includeContainer: true, getShadowRoot }) const noTabbableElements = !firstTabbable && !lastTabbable - // if we're focused on the element after the reference element and the user tabs backwards - // we want to focus the last tabbable element - if (event.shiftKey && isActiveElement(nextTabbableAfterTrigger)) { - onFocusEnter?.() - elementToFocus = lastTabbable - } - // if we're focused on the first tabbable element and the user tabs backwards - // we want to focus the reference element - else if (event.shiftKey && (isActiveElement(firstTabbable) || noTabbableElements)) { - elementToFocus = triggerElement - } - // if we're focused on the reference element and the user tabs forwards - // we want to focus the first tabbable element - else if (!event.shiftKey && isActiveElement(triggerElement)) { + let elementToFocus: MaybeElement | undefined = null + + // Cheap paths first — avoid scanning body tabbables unless exiting/re-entering content + if (event.shiftKey && (isActiveElement(firstTabbable) || noTabbableElements)) { + // Shift+Tab from first item → trigger + elementToFocus = trigger + } else if (!event.shiftKey && isActiveElement(trigger)) { + // Tab from trigger → first item onFocusEnter?.() elementToFocus = firstTabbable - } - // if we're focused on the last tabbable element and the user tabs forwards - // we want to focus the next tabbable element after the reference element - else if (!event.shiftKey && (isActiveElement(lastTabbable) || noTabbableElements)) { - elementToFocus = nextTabbableAfterTrigger + } else if (!event.shiftKey && (isActiveElement(lastTabbable) || noTabbableElements)) { + // Tab from last item → next after trigger (outside content) + elementToFocus = getNextTabbableAfterTrigger(content, trigger, getShadowRoot) + } else if (event.shiftKey) { + // Shift+Tab from next-after-trigger → last item + const nextTabbableAfterTrigger = getNextTabbableAfterTrigger(content, trigger, getShadowRoot) + if (isActiveElement(nextTabbableAfterTrigger)) { + onFocusEnter?.() + elementToFocus = lastTabbable + } } if (!elementToFocus) return @@ -63,19 +85,16 @@ function proxyTabFocusImpl(container: MaybeElement, options: ProxyTabFocusOption } } - // listen for the tab key in the capture phase return addDomEvent(doc, "keydown", onKeyDown, true) } export function proxyTabFocus(container: MaybeElementOrFn, options: ProxyTabFocusOptions) { - const { defer, triggerElement, ...restOptions } = options + const { defer, ...restOptions } = options const func = defer ? raf : (v: any) => v() const cleanups: (VoidFunction | undefined)[] = [] cleanups.push( func(() => { - const node = typeof container === "function" ? container() : container - const trigger = typeof triggerElement === "function" ? triggerElement() : triggerElement - cleanups.push(proxyTabFocusImpl(node, { triggerElement: trigger, ...restOptions })) + cleanups.push(proxyTabFocusImpl(container, restOptions)) }), ) return () => { diff --git a/packages/utilities/dom-query/tests/proxy-tab-focus.test.ts b/packages/utilities/dom-query/tests/proxy-tab-focus.test.ts new file mode 100644 index 0000000000..8cb4777343 --- /dev/null +++ b/packages/utilities/dom-query/tests/proxy-tab-focus.test.ts @@ -0,0 +1,148 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest" +import { proxyTabFocus } from "../src" + +function markVisible(element: T): T { + Object.defineProperty(element, "offsetWidth", { configurable: true, get: () => 1 }) + Object.defineProperty(element, "offsetHeight", { configurable: true, get: () => 1 }) + Object.defineProperty(element, "getClientRects", { + configurable: true, + value: () => [{ width: 1, height: 1 }], + }) + return element +} + +function createButton(text: string) { + const button = markVisible(document.createElement("button")) + button.type = "button" + button.textContent = text + return button +} + +function pressTab(shiftKey = false) { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Tab", + bubbles: true, + cancelable: true, + shiftKey, + }), + ) +} + +describe("proxyTabFocus", () => { + afterEach(() => { + document.body.innerHTML = "" + }) + + it("does not loop into portalled content when the trigger is the last page tabbable", () => { + const trigger = createButton("Trigger") + const content = markVisible(document.createElement("div")) + const first = createButton("First") + const last = createButton("Last") + content.append(first, last) + // Portalled: content is after the trigger at the end of the body + document.body.append(trigger, content) + + const onFocus = vi.fn((el: HTMLElement) => el.focus()) + const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus }) + + last.focus() + pressTab() + + expect(onFocus).not.toHaveBeenCalled() + expect(document.activeElement).toBe(last) + + cleanup() + }) + + it("tabs from the last content item to the next tabbable after the trigger", () => { + const trigger = createButton("Trigger") + const after = createButton("After") + const content = markVisible(document.createElement("div")) + const first = createButton("First") + const last = createButton("Last") + content.append(first, last) + document.body.append(trigger, after, content) + + const onFocus = vi.fn((el: HTMLElement) => el.focus()) + const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus }) + + last.focus() + pressTab() + + expect(onFocus).toHaveBeenCalledWith(after) + expect(document.activeElement).toBe(after) + + cleanup() + }) + + it("shift+tabs from the next after trigger into the last content item", () => { + const trigger = createButton("Trigger") + const after = createButton("After") + const content = markVisible(document.createElement("div")) + const first = createButton("First") + const last = createButton("Last") + content.append(first, last) + document.body.append(trigger, after, content) + + const onFocus = vi.fn((el: HTMLElement) => el.focus()) + const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus }) + + after.focus() + pressTab(true) + + expect(onFocus).toHaveBeenCalledWith(last) + expect(document.activeElement).toBe(last) + + cleanup() + }) + + it("tabs from the trigger into the first content item", () => { + const trigger = createButton("Trigger") + const content = markVisible(document.createElement("div")) + const first = createButton("First") + const last = createButton("Last") + content.append(first, last) + document.body.append(trigger, content) + + const onFocus = vi.fn((el: HTMLElement) => el.focus()) + const cleanup = proxyTabFocus(content, { triggerElement: trigger, onFocus }) + + trigger.focus() + pressTab() + + expect(onFocus).toHaveBeenCalledWith(first) + expect(document.activeElement).toBe(first) + + cleanup() + }) + + it("resolves container and trigger getters on keydown", () => { + const trigger = createButton("Trigger") + const after = createButton("After") + document.body.append(trigger, after) + + let content: HTMLElement | null = null + const onFocus = vi.fn((el: HTMLElement) => el.focus()) + const cleanup = proxyTabFocus(() => content, { + triggerElement: () => trigger, + onFocus, + }) + + content = markVisible(document.createElement("div")) + const first = createButton("First") + const last = createButton("Last") + content.append(first, last) + document.body.append(content) + + last.focus() + pressTab() + + expect(onFocus).toHaveBeenCalledWith(after) + expect(document.activeElement).toBe(after) + + cleanup() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b18a6bfb4b..3523726d01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@axe-core/playwright': - specifier: 4.11.3 - version: 4.11.3(playwright-core@1.61.0) + specifier: 4.12.1 + version: 4.12.1(playwright-core@1.61.1) '@changesets/changelog-github': specifier: 0.7.0 version: 0.7.0 @@ -30,8 +30,8 @@ importers: specifier: 3.12.2 version: 3.12.2 '@playwright/test': - specifier: 1.61.0 - version: 1.61.0 + specifier: 1.61.1 + version: 1.61.1 '@swc/core': specifier: 1.15.41 version: 1.15.41(@swc/helpers@0.5.23) @@ -379,10 +379,10 @@ importers: version: 1.16.0(react@19.2.6) next: specifier: 16.2.6 - version: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next-cloudinary: specifier: 6.17.5 - version: 6.17.5(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) + version: 6.17.5(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) react: specifier: 19.2.6 version: 19.2.6 @@ -3814,7 +3814,7 @@ importers: version: 8.3.0 next: specifier: 16.2.9 - version: 16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -4056,10 +4056,10 @@ importers: version: 8.3.0 next: specifier: 16.2.9 - version: 16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-seo: specifier: 7.2.0 - version: 7.2.0(next@16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + version: 7.2.0(next@16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -4151,8 +4151,8 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@axe-core/playwright@4.11.3': - resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + '@axe-core/playwright@4.12.1': + resolution: {integrity: sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==} peerDependencies: playwright-core: '>= 1.0.0' @@ -6217,8 +6217,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.61.0': - resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} engines: {node: '>=18'} hasBin: true @@ -7754,10 +7754,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.4: - resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} - engines: {node: '>=4'} - axe-core@4.12.1: resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} engines: {node: '>=4'} @@ -11138,13 +11134,13 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - playwright-core@1.61.0: - resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} engines: {node: '>=18'} hasBin: true - playwright@1.61.0: - resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} engines: {node: '>=18'} hasBin: true @@ -13395,10 +13391,10 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@axe-core/playwright@4.11.3(playwright-core@1.61.0)': + '@axe-core/playwright@4.12.1(playwright-core@1.61.1)': dependencies: - axe-core: 4.11.4 - playwright-core: 1.61.0 + axe-core: 4.12.1 + playwright-core: 1.61.1 '@babel/code-frame@7.26.2': dependencies: @@ -15529,9 +15525,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.61.0': + '@playwright/test@1.61.1': dependencies: - playwright: 1.61.0 + playwright: 1.61.1 '@pnpm/constants@6.1.0': {} @@ -17235,8 +17231,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.4: {} - axe-core@4.12.1: {} axobject-query@4.1.0: {} @@ -20637,17 +20631,17 @@ snapshots: neo-async@2.6.2: {} - next-cloudinary@6.17.5(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): + next-cloudinary@6.17.5(next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): dependencies: '@cloudinary-util/types': 1.5.10 '@cloudinary-util/url-loader': 5.10.4 '@cloudinary-util/util': 4.0.0 - next: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + next: 16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: 19.2.6 - next-seo@7.2.0(next@16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + next-seo@7.2.0(next@16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): dependencies: - next: 16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: 19.2.7 next-themes@0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7): @@ -20655,7 +20649,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + next@16.2.6(@babel/core@7.29.7)(@playwright/test@1.61.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -20674,13 +20668,13 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.6 '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 - '@playwright/test': 1.61.0 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros - next@16.2.9(@playwright/test@1.61.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.9(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.9 '@swc/helpers': 0.5.15 @@ -20699,7 +20693,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.9 '@next/swc-win32-arm64-msvc': 16.2.9 '@next/swc-win32-x64-msvc': 16.2.9 - '@playwright/test': 1.61.0 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -21554,11 +21548,11 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - playwright-core@1.61.0: {} + playwright-core@1.61.1: {} - playwright@1.61.0: + playwright@1.61.1: dependencies: - playwright-core: 1.61.0 + playwright-core: 1.61.1 optionalDependencies: fsevents: 2.3.2