Skip to content

Commit 7427349

Browse files
authored
RAC Toolbar component (#5080)
* RAC Toolbar component
1 parent 21501b7 commit 7427349

File tree

18 files changed

+1541
-20
lines changed

18 files changed

+1541
-20
lines changed

packages/@react-aria/actiongroup/src/useActionGroup.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
import {AriaActionGroupProps} from '@react-types/actiongroup';
1414
import {createFocusManager} from '@react-aria/focus';
1515
import {DOMAttributes, FocusableElement, Orientation} from '@react-types/shared';
16-
import {filterDOMProps} from '@react-aria/utils';
16+
import {filterDOMProps, useLayoutEffect} from '@react-aria/utils';
1717
import {ListState} from '@react-stately/list';
18-
import {RefObject} from 'react';
18+
import {RefObject, useState} from 'react';
1919
import {useLocale} from '@react-aria/i18n';
2020

2121
const BUTTON_GROUP_ROLES = {
@@ -33,6 +33,15 @@ export function useActionGroup<T>(props: AriaActionGroupProps<T>, state: ListSta
3333
isDisabled,
3434
orientation = 'horizontal' as Orientation
3535
} = props;
36+
37+
let [isInToolbar, setInToolbar] = useState(false);
38+
// should be safe because re-calling set state with the same value it already has is a no-op
39+
// this will allow us to react should a parent re-render and change its role though
40+
// eslint-disable-next-line react-hooks/exhaustive-deps
41+
useLayoutEffect(() => {
42+
setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]')));
43+
});
44+
3645
let allKeys = [...state.collection.getKeys()];
3746
if (!allKeys.some(key => !state.disabledKeys.has(key))) {
3847
isDisabled = true;
@@ -70,7 +79,10 @@ export function useActionGroup<T>(props: AriaActionGroupProps<T>, state: ListSta
7079
}
7180
};
7281

73-
let role = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode];
82+
let role: string | undefined = BUTTON_GROUP_ROLES[state.selectionManager.selectionMode];
83+
if (isInToolbar && role === 'toolbar') {
84+
role = 'group';
85+
}
7486
return {
7587
actionGroupProps: {
7688
...filterDOMProps(props, {labelable: true}),

packages/@react-aria/actiongroup/test/useActionGroup.test.js renamed to packages/@react-aria/actiongroup/test/useActionGroup.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import React from 'react';
13+
import {createRef} from 'react';
14+
import {FocusableElement} from '@react-types/shared';
1415
import {renderHook} from '@react-spectrum/test-utils';
1516
import {useActionGroup} from '../';
1617
import {useListState} from '@react-stately/list';
1718

1819
describe('useActionGroup', function () {
1920
let renderActionGroupHook = (props) => {
20-
let {result} = renderHook(() => useActionGroup(props, useListState(props)));
21+
let ref = createRef<FocusableElement>();
22+
let {result} = renderHook(() => useActionGroup(props, useListState(props), ref));
2123
return result.current;
2224
};
2325

packages/@react-aria/numberfield/src/useNumberField.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
185185
let onKeyDownEnter = useCallback((e) => {
186186
if (e.key === 'Enter') {
187187
commit();
188+
} else {
189+
e.continuePropagation();
188190
}
189191
}, [commit]);
190192

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# @react-aria/toolbar
2+
3+
This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details.

packages/@react-aria/toolbar/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2023 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
export * from './src';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@react-aria/toolbar",
3+
"version": "3.0.0-alpha.1",
4+
"description": "Spectrum UI components in React",
5+
"license": "Apache-2.0",
6+
"main": "dist/main.js",
7+
"module": "dist/module.js",
8+
"types": "dist/types.d.ts",
9+
"exports": {
10+
"types": "./dist/types.d.ts",
11+
"import": "./dist/import.mjs",
12+
"require": "./dist/main.js"
13+
},
14+
"source": "src/index.ts",
15+
"files": [
16+
"dist",
17+
"src"
18+
],
19+
"sideEffects": false,
20+
"repository": {
21+
"type": "git",
22+
"url": "https://github.com/adobe/react-spectrum"
23+
},
24+
"dependencies": {
25+
"@react-aria/focus": "^3.14.1",
26+
"@react-aria/i18n": "^3.8.2",
27+
"@react-aria/utils": "^3.20.0",
28+
"@react-types/shared": "^3.20.0",
29+
"@swc/helpers": "^0.5.0"
30+
},
31+
"peerDependencies": {
32+
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0"
33+
},
34+
"publishConfig": {
35+
"access": "public"
36+
}
37+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2023 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
export * from './useToolbar';
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/*
2+
* Copyright 2023 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {AriaLabelingProps, MultipleSelection, Orientation} from '@react-types/shared';
14+
import {createFocusManager} from '@react-aria/focus';
15+
import {HTMLAttributes, KeyboardEventHandler, RefObject, useRef, useState} from 'react';
16+
import {useLayoutEffect} from '@react-aria/utils';
17+
import {useLocale} from '@react-aria/i18n';
18+
19+
export interface AriaToolbarProps extends AriaLabelingProps, MultipleSelection {
20+
/**
21+
* The orientation of the entire toolbar.
22+
* @default 'horizontal'
23+
*/
24+
orientation?: Orientation
25+
}
26+
27+
interface ToolbarAria {
28+
toolbarProps: HTMLAttributes<HTMLElement>
29+
}
30+
31+
/**
32+
* Handles interactions for toolbar elements, such as keyboard navigation between elements.
33+
*/
34+
export function useToolbar(props: AriaToolbarProps, ref: RefObject<HTMLDivElement>): ToolbarAria {
35+
const {
36+
'aria-label': ariaLabel,
37+
'aria-labelledby': ariaLabelledBy,
38+
orientation = 'horizontal'
39+
} = props;
40+
let [isInToolbar, setInToolbar] = useState(false);
41+
// should be safe because re-calling set state with the same value it already has is a no-op
42+
// this will allow us to react should a parent re-render and change its role though
43+
// eslint-disable-next-line react-hooks/exhaustive-deps
44+
useLayoutEffect(() => {
45+
setInToolbar(!!(ref.current && ref.current.parentElement?.closest('[role="toolbar"]')));
46+
});
47+
const {direction} = useLocale();
48+
const shouldReverse = direction === 'rtl' && orientation === 'horizontal';
49+
let focusManager = createFocusManager(ref);
50+
51+
const onKeyDown: KeyboardEventHandler = (e) => {
52+
// don't handle portalled events
53+
if (!e.currentTarget.contains(e.target as HTMLElement)) {
54+
return;
55+
}
56+
if (
57+
(orientation === 'horizontal' && e.key === 'ArrowRight')
58+
|| (orientation === 'vertical' && e.key === 'ArrowDown')) {
59+
if (shouldReverse) {
60+
focusManager.focusPrevious();
61+
} else {
62+
focusManager.focusNext();
63+
}
64+
} else if (
65+
(orientation === 'horizontal' && e.key === 'ArrowLeft')
66+
|| (orientation === 'vertical' && e.key === 'ArrowUp')) {
67+
if (shouldReverse) {
68+
focusManager.focusNext();
69+
} else {
70+
focusManager.focusPrevious();
71+
}
72+
} else if (e.key === 'Tab') {
73+
// When the tab key is pressed, we want to move focus
74+
// out of the entire toolbar. To do this, move focus
75+
// to the first or last focusable child, and let the
76+
// browser handle the Tab key as usual from there.
77+
e.stopPropagation();
78+
lastFocused.current = document.activeElement as HTMLElement;
79+
if (e.shiftKey) {
80+
focusManager.focusFirst();
81+
} else {
82+
focusManager.focusLast();
83+
}
84+
return;
85+
} else {
86+
// if we didn't handle anything, return early so we don't preventDefault
87+
return;
88+
}
89+
90+
// Prevent arrow keys from being handled by nested action groups.
91+
e.stopPropagation();
92+
e.preventDefault();
93+
};
94+
95+
// Record the last focused child when focus moves out of the toolbar.
96+
const lastFocused = useRef<HTMLElement | null>(null);
97+
const onBlur = (e) => {
98+
if (!e.currentTarget.contains(e.relatedTarget) && !lastFocused.current) {
99+
lastFocused.current = e.target;
100+
}
101+
};
102+
103+
// Restore focus to the last focused child when focus returns into the toolbar.
104+
// If the element was removed, do nothing, either the first item in the first group,
105+
// or the last item in the last group will be focused, depending on direction.
106+
const onFocus = (e) => {
107+
if (lastFocused.current && !e.currentTarget.contains(e.relatedTarget) && ref.current?.contains(e.target)) {
108+
lastFocused.current?.focus();
109+
lastFocused.current = null;
110+
}
111+
};
112+
113+
return {
114+
toolbarProps: {
115+
role: !isInToolbar ? 'toolbar' : 'group',
116+
'aria-orientation': orientation,
117+
'aria-label': ariaLabel,
118+
'aria-labelledby': ariaLabel == null ? ariaLabelledBy : undefined,
119+
onKeyDownCapture: !isInToolbar ? onKeyDown : undefined,
120+
onFocusCapture: !isInToolbar ? onFocus : undefined,
121+
onBlurCapture: !isInToolbar ? onBlur : undefined
122+
}
123+
};
124+
}

0 commit comments

Comments
 (0)