Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export default [{
"rsp-rules/safe-event-target": [ERROR],
"rsp-rules/shadow-safe-active-element": [ERROR],
"rsp-rules/faster-node-contains": [ERROR],
"rsp-rules/safe-root-focus-listener": [ERROR],
"rulesdir/imports": [ERROR],
"rulesdir/useLayoutEffectRule": [ERROR],
"rulesdir/pure-render": [ERROR],
Expand Down Expand Up @@ -436,6 +437,7 @@ export default [{
"rsp-rules/safe-event-target": OFF,
"rsp-rules/shadow-safe-active-element": OFF,
"rsp-rules/faster-node-contains": OFF,
"rsp-rules/safe-root-focus-listener": OFF,
"rulesdir/imports": OFF,
"monorepo/no-internal-import": OFF,
"jsdoc/require-jsdoc": OFF
Expand Down Expand Up @@ -525,6 +527,12 @@ export default [{
rules: {
"react/react-in-jsx-scope": OFF,
},
}, {
files: ["packages/dev/s2-docs/**"],

rules: {
"rsp-rules/safe-root-focus-listener": OFF,
},
}, {
files: ["packages/dev/style-macro-chrome-plugin/**"],
languageOptions: {
Expand Down
54 changes: 36 additions & 18 deletions packages/@react-aria/interactions/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {FocusableElement} from '@react-types/shared';
import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, useLayoutEffect} from '@react-aria/utils';
import {focusWithoutScrolling, getActiveElement, getEventTarget, getOwnerWindow, isFocusable, isShadowRoot, nodeContains, useLayoutEffect} from '@react-aria/utils';
import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react';

// Turn a native event into a React synthetic event.
Expand Down Expand Up @@ -110,21 +110,39 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}

let window = getOwnerWindow(target);
let activeElement = window.document.activeElement as FocusableElement | null;
let activeElement = getActiveElement(window.document) as FocusableElement | null;
if (!activeElement || activeElement === target) {
return;
}

// Listen on the target's root (document or shadow root) so we catch focus events inside
// shadow DOM; they do not reach the main window.
let targetRoot = target?.getRootNode();
let root =
(targetRoot != null && isShadowRoot(targetRoot))
? targetRoot
: getOwnerWindow(target);

// Focus is "moving to target" when it moves to the button or to a descendant of the button
// (e.g. SVG icon)
let isFocusMovingToTarget = (focusTarget: Element | null) =>
focusTarget === target || (focusTarget != null && nodeContains(target, focusTarget));
// Blur/focusout events have their target as the element losing focus. Stop propagation when
// that is the previously focused element (activeElement) or a descendant (e.g. in shadow DOM).
let isBlurFromActiveElement = (eventTarget: Element | null) =>
eventTarget === activeElement ||
(activeElement != null && eventTarget != null && nodeContains(activeElement, eventTarget));

ignoreFocusEvent = true;
let isRefocusing = false;
let onBlur = (e: FocusEvent) => {
if (getEventTarget(e) === activeElement || isRefocusing) {
let onBlur: EventListener = (e) => {
if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();
}
};

let onFocusOut = (e: FocusEvent) => {
if (getEventTarget(e) === activeElement || isRefocusing) {
let onFocusOut: EventListener = (e) => {
if (isBlurFromActiveElement(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();

// If there was no focusable ancestor, we don't expect a focus event.
Expand All @@ -137,14 +155,14 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}
};

let onFocus = (e: FocusEvent) => {
if (getEventTarget(e) === target || isRefocusing) {
let onFocus: EventListener = (e) => {
if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();
}
};

let onFocusIn = (e: FocusEvent) => {
if (getEventTarget(e) === target || isRefocusing) {
let onFocusIn: EventListener = (e) => {
if (isFocusMovingToTarget(getEventTarget(e) as Element) || isRefocusing) {
e.stopImmediatePropagation();

if (!isRefocusing) {
Expand All @@ -155,17 +173,17 @@ export function preventFocus(target: FocusableElement | null): (() => void) | un
}
};

window.addEventListener('blur', onBlur, true);
window.addEventListener('focusout', onFocusOut, true);
window.addEventListener('focusin', onFocusIn, true);
window.addEventListener('focus', onFocus, true);
root.addEventListener('blur', onBlur, true);
root.addEventListener('focusout', onFocusOut, true);
root.addEventListener('focusin', onFocusIn, true);
root.addEventListener('focus', onFocus, true);

let cleanup = () => {
cancelAnimationFrame(raf);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('focusout', onFocusOut, true);
window.removeEventListener('focusin', onFocusIn, true);
window.removeEventListener('focus', onFocus, true);
root.removeEventListener('blur', onBlur, true);
root.removeEventListener('focusout', onFocusOut, true);
root.removeEventListener('focusin', onFocusIn, true);
root.removeEventListener('focus', onFocus, true);
ignoreFocusEvent = false;
isRefocusing = false;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/dev/eslint-plugin-rsp-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import noGetByRoleToThrow from './rules/no-getByRole-toThrow.js';
import noNonShadowContains from './rules/no-non-shadow-contains.js';
import noReactKey from './rules/no-react-key.js';
import safeEventTarget from './rules/safe-event-target.js';
import safeRootFocusListener from './rules/safe-root-focus-listener.js';
import shadowSafeActiveElement from './rules/shadow-safe-active-element.js';
import sortImports from './rules/sort-imports.js';

Expand All @@ -26,6 +27,7 @@ const rules = {
'sort-imports': sortImports,
'no-non-shadow-contains': noNonShadowContains,
'safe-event-target': safeEventTarget,
'safe-root-focus-listener': safeRootFocusListener,
'shadow-safe-active-element': shadowSafeActiveElement,
'faster-node-contains': fasterNodeContains
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

const FOCUS_EVENT_NAMES = new Set(['blur', 'focus', 'focusin', 'focusout']);
const DISALLOWED_TARGETS = new Set(['window', 'document']);

const plugin = {
meta: {
type: 'problem',
docs: {
description: 'Disallow attaching focus-related listeners (blur, focus, focusin, focusout) to the window or document object. This will not work in shadow DOM as focus and blur do not bubble past the shadow boundary.',
recommended: true
},
schema: [],
messages: {
noRootFocusListener: 'Do not attach focus listeners (blur, focus, focusin, focusout) to window or document. Use a root element instead for shadow DOM compatibility.'
}
},
create: (context) => {
return {
CallExpression(node) {
if (node.callee.type !== 'MemberExpression') {
return;
}
const {object, property} = node.callee;
if (object.type !== 'Identifier' || !DISALLOWED_TARGETS.has(object.name)) {
return;
}
if (property.type !== 'Identifier') {
return;
}
const method = property.name;
if (method !== 'addEventListener' && method !== 'removeEventListener') {
return;
}
if (node.arguments.length === 0) {
return;
}
const eventNameArg = node.arguments[0];
const eventName = eventNameArg.type === 'Literal' && typeof eventNameArg.value === 'string'
? eventNameArg.value
: null;
if (eventName == null || !FOCUS_EVENT_NAMES.has(eventName)) {
return;
}
context.report({
node,
messageId: 'noRootFocusListener'
});
}
};
}
};

export default plugin;
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright 2023 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {RuleTester} from 'eslint';
import safeRootFocusListenerRule from '../rules/safe-root-focus-listener.js';

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2015,
sourceType: 'module'
}
});

// Throws error if the tests in ruleTester.run() do not pass
ruleTester.run(
'safe-root-focus-listener',
safeRootFocusListenerRule,
{
// 'valid' checks cases that should pass
valid: [
{
code: `
root.addEventListener('blur', onBlur, true);
root.addEventListener('focusout', onFocusOut, true);
root.addEventListener('focusin', onFocusIn, true);
root.addEventListener('focus', onFocus, true);
root.removeEventListener('blur', onBlur, true);
root.removeEventListener('focusout', onFocusOut, true);
root.removeEventListener('focusin', onFocusIn, true);
root.removeEventListener('focus', onFocus, true);
`
}
],
// 'invalid' checks cases that should not pass
invalid: [
{
code: `
window.addEventListener('blur', onBlur, true);
window.addEventListener('focusout', onFocusOut, true);
window.addEventListener('focusin', onFocusIn, true);
window.addEventListener('focus', onFocus, true);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('focusout', onFocusOut, true);
window.removeEventListener('focusin', onFocusIn, true);
window.removeEventListener('focus', onFocus, true);
document.addEventListener('blur', onBlur, true);
document.addEventListener('focusout', onFocusOut, true);
document.addEventListener('focusin', onFocusIn, true);
document.addEventListener('focus', onFocus, true);
document.removeEventListener('blur', onBlur, true);
document.removeEventListener('focusout', onFocusOut, true);
document.removeEventListener('focusin', onFocusIn, true);
document.removeEventListener('focus', onFocus, true);
`,
errors: 16
}
]
}
);