Skip to content

Commit 740b6e1

Browse files
author
Michael Jordan
authored
Merge branch 'main' into color-a11y-i18n
2 parents c07dacd + d66cf4d commit 740b6e1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+2953
-267
lines changed

packages/@react-aria/accordion/src/useAccordion.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function useAccordionItem<T>(props: AccordionItemAriaProps<T>, state: Tre
5050
isDisabled,
5151
onPress: () => state.toggleKey(item.key)
5252
}), ref);
53-
let isExpanded = state.expandedKeys.has(item.key);
53+
let isExpanded = state.expandedKeys === 'all' || state.expandedKeys.has(item.key);
5454
return {
5555
buttonProps: {
5656
...buttonProps,

packages/@react-aria/dnd/src/useClipboard.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export interface ClipboardProps {
2424
/** Handler that is called when the user triggers a cut interaction. */
2525
onCut?: () => void,
2626
/** Handler that is called when the user triggers a paste interaction. */
27-
onPaste?: (items: DropItem[]) => void
27+
onPaste?: (items: DropItem[]) => void,
28+
/** Whether the clipboard is disabled. */
29+
isDisabled?: boolean
2830
}
2931

3032
export interface ClipboardResult {
@@ -64,6 +66,7 @@ function addGlobalEventListener(event, fn) {
6466
* data types, and integrates with the operating system native clipboard.
6567
*/
6668
export function useClipboard(options: ClipboardProps): ClipboardResult {
69+
let {isDisabled} = options;
6770
let isFocusedRef = useRef(false);
6871
let {focusProps} = useFocus({
6972
onFocusChange: (isFocused) => {
@@ -123,6 +126,9 @@ export function useClipboard(options: ClipboardProps): ClipboardResult {
123126
});
124127

125128
useEffect(() => {
129+
if (isDisabled) {
130+
return;
131+
}
126132
return chain(
127133
addGlobalEventListener('beforecopy', onBeforeCopy),
128134
addGlobalEventListener('copy', onCopy),
@@ -131,7 +137,7 @@ export function useClipboard(options: ClipboardProps): ClipboardResult {
131137
addGlobalEventListener('beforepaste', onBeforePaste),
132138
addGlobalEventListener('paste', onPaste)
133139
);
134-
}, [onBeforeCopy, onCopy, onBeforeCut, onCut, onBeforePaste, onPaste]);
140+
}, [isDisabled, onBeforeCopy, onCopy, onBeforeCut, onCut, onBeforePaste, onPaste]);
135141

136142
return {
137143
clipboardProps: focusProps

packages/@react-aria/dnd/src/useDrop.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {AriaButtonProps} from '@react-types/button';
14-
import {DragEvent, HTMLAttributes, RefObject, useRef, useState} from 'react';
14+
import {DragEvent, HTMLAttributes, RefObject, useRef, useState} from 'react';
1515
import * as DragManager from './DragManager';
1616
import {DragTypes, globalAllowedDropOperations, globalDndState, readFromDataTransfer, setGlobalDnDState, setGlobalDropEffect} from './utils';
1717
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants';
@@ -47,7 +47,11 @@ export interface DropOptions {
4747
* Whether the item has an explicit focusable drop affordance to initiate accessible drag and drop mode.
4848
* If true, the dropProps will omit these event handlers, and they will be applied to dropButtonProps instead.
4949
*/
50-
hasDropButton?: boolean
50+
hasDropButton?: boolean,
51+
/**
52+
* Whether the drop target is disabled. If true, the drop target will not accept any drops.
53+
*/
54+
isDisabled?: boolean
5155
}
5256

5357
export interface DropResult {
@@ -57,7 +61,6 @@ export interface DropResult {
5761
isDropTarget: boolean,
5862
/** Props for the explicit drop button affordance, if any. */
5963
dropButtonProps?: AriaButtonProps
60-
6164
}
6265

6366
const DROP_ACTIVATE_TIMEOUT = 800;
@@ -67,7 +70,7 @@ const DROP_ACTIVATE_TIMEOUT = 800;
6770
* based drag and drop, in addition to full parity for keyboard and screen reader users.
6871
*/
6972
export function useDrop(options: DropOptions): DropResult {
70-
let {hasDropButton} = options;
73+
let {hasDropButton, isDisabled} = options;
7174
let [isDropTarget, setDropTarget] = useState(false);
7275
let state = useRef({
7376
x: 0,
@@ -311,23 +314,34 @@ export function useDrop(options: DropOptions): DropResult {
311314
});
312315

313316
let {ref} = options;
314-
useLayoutEffect(() => DragManager.registerDropTarget({
315-
element: ref.current,
316-
getDropOperation: getDropOperationKeyboard,
317-
onDropEnter(e) {
318-
setDropTarget(true);
319-
onDropEnter(e);
320-
},
321-
onDropExit(e) {
322-
setDropTarget(false);
323-
onDropExit(e);
324-
},
325-
onDrop: onKeyboardDrop,
326-
onDropActivate
327-
}), [ref, getDropOperationKeyboard, onDropEnter, onDropExit, onKeyboardDrop, onDropActivate]);
317+
useLayoutEffect(() => {
318+
if (isDisabled) {
319+
return;
320+
}
321+
return DragManager.registerDropTarget({
322+
element: ref.current,
323+
getDropOperation: getDropOperationKeyboard,
324+
onDropEnter(e) {
325+
setDropTarget(true);
326+
onDropEnter(e);
327+
},
328+
onDropExit(e) {
329+
setDropTarget(false);
330+
onDropExit(e);
331+
},
332+
onDrop: onKeyboardDrop,
333+
onDropActivate
334+
});
335+
}, [isDisabled, ref, getDropOperationKeyboard, onDropEnter, onDropExit, onKeyboardDrop, onDropActivate]);
328336

329337
let {dropProps} = useVirtualDrop();
330-
338+
if (isDisabled) {
339+
return {
340+
dropProps: {},
341+
dropButtonProps: {isDisabled: true},
342+
isDropTarget: false
343+
};
344+
}
331345
return {
332346
dropProps: {
333347
...(!hasDropButton && dropProps),

packages/@react-aria/gridlist/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
"@react-aria/interactions": "^3.21.1",
2929
"@react-aria/selection": "^3.17.5",
3030
"@react-aria/utils": "^3.23.2",
31+
"@react-stately/collections": "^3.10.4",
3132
"@react-stately/list": "^3.10.3",
33+
"@react-stately/tree": "^3.7.5",
3234
"@react-types/shared": "^3.22.1",
3335
"@swc/helpers": "^0.5.0"
3436
},

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212

1313
import {DOMAttributes, FocusableElement, Node as RSNode} from '@react-types/shared';
1414
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
15+
import {getLastItem} from '@react-stately/collections';
1516
import {getRowId, listMap} from './utils';
1617
import {getScrollParent, getSyntheticLinkProps, mergeProps, scrollIntoViewport, useSlotId} from '@react-aria/utils';
18+
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, RefObject, useRef} from 'react';
1719
import {isFocusVisible} from '@react-aria/interactions';
1820
import type {ListState} from '@react-stately/list';
19-
import {KeyboardEvent as ReactKeyboardEvent, RefObject, useRef} from 'react';
2021
import {SelectableItemStates, useSelectableItem} from '@react-aria/selection';
22+
import type {TreeState} from '@react-stately/tree';
2123
import {useLocale} from '@react-aria/i18n';
2224

2325
export interface AriaGridListItemOptions {
@@ -38,13 +40,24 @@ export interface GridListItemAria extends SelectableItemStates {
3840
descriptionProps: DOMAttributes
3941
}
4042

43+
const EXPANSION_KEYS = {
44+
'expand': {
45+
ltr: 'ArrowRight',
46+
rtl: 'ArrowLeft'
47+
},
48+
'collapse': {
49+
ltr: 'ArrowLeft',
50+
rtl: 'ArrowRight'
51+
}
52+
};
53+
4154
/**
4255
* Provides the behavior and accessibility implementation for a row in a grid list.
4356
* @param props - Props for the row.
4457
* @param state - State of the parent list, as returned by `useListState`.
4558
* @param ref - The ref attached to the row element.
4659
*/
47-
export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListState<T>, ref: RefObject<FocusableElement>): GridListItemAria {
60+
export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListState<T> | TreeState<T>, ref: RefObject<FocusableElement>): GridListItemAria {
4861
// Copied from useGridCell + some modifications to make it not so grid specific
4962
let {
5063
node,
@@ -64,12 +77,34 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
6477
// (e.g. clicking on a row button)
6578
if (
6679
(keyWhenFocused.current != null && node.key !== keyWhenFocused.current) ||
67-
!ref.current.contains(document.activeElement)
80+
!ref.current?.contains(document.activeElement)
6881
) {
6982
focusSafely(ref.current);
7083
}
7184
};
7285

86+
let treeGridRowProps: HTMLAttributes<HTMLElement> = {};
87+
let hasChildRows;
88+
let hasLink = state.selectionManager.isLink(node.key);
89+
if (node != null && 'expandedKeys' in state) {
90+
// TODO: ideally node.hasChildNodes would be a way to tell if a row has child nodes, but the row's contents make it so that value is always
91+
// true...
92+
hasChildRows = [...state.collection.getChildren(node.key)].length > 1;
93+
if (onAction == null && !hasLink && state.selectionManager.selectionMode === 'none' && hasChildRows) {
94+
onAction = () => state.toggleKey(node.key);
95+
}
96+
97+
let isExpanded = hasChildRows ? state.expandedKeys === 'all' || state.expandedKeys.has(node.key) : undefined;
98+
treeGridRowProps = {
99+
'aria-expanded': isExpanded,
100+
'aria-level': node.level + 1,
101+
'aria-posinset': node?.index + 1,
102+
'aria-setsize': node.level > 0 ?
103+
(getLastItem(state.collection.getChildren(node?.parentKey))).index + 1 :
104+
[...state.collection].filter(row => row.level === 0).at(-1).index + 1
105+
};
106+
}
107+
73108
let {itemProps, ...itemStates} = useSelectableItem({
74109
selectionManager: state.selectionManager,
75110
key: node.key,
@@ -89,6 +124,18 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
89124
let walker = getFocusableTreeWalker(ref.current);
90125
walker.currentNode = document.activeElement;
91126

127+
if ('expandedKeys' in state && document.activeElement === ref.current) {
128+
if ((e.key === EXPANSION_KEYS['expand'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && state.expandedKeys !== 'all' && !state.expandedKeys.has(node.key)) {
129+
state.toggleKey(node.key);
130+
e.stopPropagation();
131+
return;
132+
} else if ((e.key === EXPANSION_KEYS['collapse'][direction]) && state.selectionManager.focusedKey === node.key && hasChildRows && (state.expandedKeys === 'all' || state.expandedKeys.has(node.key))) {
133+
state.toggleKey(node.key);
134+
e.stopPropagation();
135+
return;
136+
}
137+
}
138+
92139
switch (e.key) {
93140
case 'ArrowLeft': {
94141
// Find the next focusable element within the row.
@@ -199,8 +246,9 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
199246
'aria-colindex': 1
200247
};
201248

249+
// TODO: should isExpanded and hasChildRows be a item state that gets returned by the hook?
202250
return {
203-
rowProps,
251+
rowProps: {...mergeProps(rowProps, treeGridRowProps)},
204252
gridCellProps,
205253
descriptionProps: {
206254
id: descriptionId

packages/@react-aria/interactions/src/useFocus.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared';
1919
import {FocusEvent, useCallback} from 'react';
20+
import {getOwnerDocument} from '@react-aria/utils';
2021
import {useSyntheticBlurEvent} from './utils';
2122

2223
export interface FocusProps<Target = FocusableElement> extends FocusEvents<Target> {
@@ -61,7 +62,10 @@ export function useFocus<Target extends FocusableElement = FocusableElement>(pro
6162
const onFocus: FocusProps<Target>['onFocus'] = useCallback((e: FocusEvent<Target>) => {
6263
// Double check that document.activeElement actually matches e.target in case a previously chained
6364
// focus handler already moved focus somewhere else.
64-
if (e.target === e.currentTarget && document.activeElement === e.target) {
65+
66+
const ownerDocument = getOwnerDocument(e.target);
67+
68+
if (e.target === e.currentTarget && ownerDocument.activeElement === e.target) {
6569
if (onFocusProp) {
6670
onFocusProp(e);
6771
}

packages/@react-aria/interactions/stories/useFocusRing.stories.tsx

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {addWindowFocusTracking} from '../src';
1314
import {Cell, Column, Row, TableBody, TableHeader, TableView} from '@react-spectrum/table';
1415
import {Key} from '@react-types/shared';
15-
import React, {useState} from 'react';
16+
import {mergeProps} from '@react-aria/utils';
17+
import React, {useEffect, useRef, useState} from 'react';
18+
import ReactDOM from 'react-dom';
1619
import {SearchField} from '@react-spectrum/searchfield';
20+
import {useButton} from '@react-aria/button';
21+
import {useFocusRing} from '@react-aria/focus';
1722

1823
interface IColumn {
1924
name: string,
@@ -59,6 +64,11 @@ export const SearchTableview = {
5964
}
6065
};
6166

67+
export const IFrame = {
68+
render: () => <IFrameExample />,
69+
name: 'focus state in dynamic iframe'
70+
};
71+
6272
function SearchExample() {
6373
const [items, setItems] = useState(manyRows);
6474

@@ -89,3 +99,51 @@ function SearchExample() {
8999
</div>
90100
);
91101
}
102+
103+
function Button() {
104+
const buttonRef = useRef<HTMLButtonElement | null>(null);
105+
106+
const {buttonProps} = useButton({}, buttonRef);
107+
const {focusProps, isFocusVisible, isFocused} = useFocusRing();
108+
109+
return (
110+
<button ref={buttonRef} {...mergeProps(buttonProps, focusProps)}>
111+
Focus Visible: {isFocusVisible ? 'true' : 'false'} <br />
112+
Focused: {isFocused ? 'true' : 'false'}
113+
</button>
114+
);
115+
}
116+
117+
const IframeWrapper = ({children}) => {
118+
const iframeRef = useRef<HTMLIFrameElement | null>(null);
119+
120+
useEffect(() => {
121+
if (iframeRef.current) {
122+
const main = document.createElement('main');
123+
const iframeDocument = iframeRef.current.contentDocument;
124+
125+
if (iframeDocument) {
126+
iframeDocument.body.innerHTML = '';
127+
iframeDocument.body.appendChild(main);
128+
ReactDOM.render(children, main);
129+
130+
return addWindowFocusTracking(iframeDocument.body);
131+
}
132+
}
133+
}, [children]);
134+
135+
return <iframe title="test" ref={iframeRef} />;
136+
};
137+
138+
function IFrameExample() {
139+
return (
140+
<>
141+
<Button />
142+
<IframeWrapper>
143+
<Button />
144+
<Button />
145+
<Button />
146+
</IframeWrapper>
147+
</>
148+
);
149+
}

0 commit comments

Comments
 (0)