Skip to content

Commit 5360a76

Browse files
authored
Adding automatic scrolling on item focus for non-virtualized collections (#2232)
* moving useSelectableList scrolling code to useSelectableCollection allows non virtualized tables/grids created with useTable/useGrid to have automatic scrolling when moving focus via arrow keys * adding stories and fixing useSelectableCollection scroll behavior some components will have a ref that is not the same element as the scrollable region so add a prop for scrollRef * fixing lint * fixing lint * addressing review comments * fixing lint and removing isVirtualized from tablist props
1 parent 185cd9d commit 5360a76

File tree

16 files changed

+905
-88
lines changed

16 files changed

+905
-88
lines changed

packages/@react-aria/combobox/docs/useComboBox.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,7 @@ function Popover(props) {
242242
popoverRef = ref,
243243
isOpen,
244244
onClose,
245-
children,
246-
...otherProps
245+
children
247246
} = props;
248247

249248
// Handle events that should cause the popup to close,
@@ -303,7 +302,9 @@ function ListBox(props) {
303302
style={{
304303
margin: 0,
305304
padding: 0,
306-
listStyle: "none"
305+
listStyle: "none",
306+
maxHeight: "150px",
307+
overflow: "auto"
307308
}}>
308309
{[...state.collection].map(item => (
309310
<Option

packages/@react-aria/combobox/src/useComboBox.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ export function useComboBox<T>(props: AriaComboBoxProps<T>, state: ComboBoxState
9494
keyboardDelegate: delegate,
9595
disallowTypeAhead: true,
9696
disallowEmptySelection: true,
97-
ref: inputRef
97+
ref: inputRef,
98+
// Prevent item scroll behavior from being applied here, should be handled in the user's Popover + ListBox component
99+
isVirtualized: true
98100
});
99101

100102
// For textfield specific keydown operations
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright 2021 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 {DismissButton, useOverlay} from '@react-aria/overlays';
14+
import {FocusScope} from '@react-aria/focus';
15+
import React from 'react';
16+
import {useButton} from '@react-aria/button';
17+
import {useComboBox} from '@react-aria/combobox';
18+
import {useComboBoxState} from '@react-stately/combobox';
19+
import {useFilter} from '@react-aria/i18n';
20+
import {useListBox, useOption} from '@react-aria/listbox';
21+
22+
export function ComboBox(props) {
23+
// Setup filter function and state.
24+
let {contains} = useFilter({sensitivity: 'base'});
25+
let state = useComboBoxState({...props, defaultFilter: contains});
26+
27+
// Setup refs and get props for child elements.
28+
let buttonRef = React.useRef(null);
29+
let inputRef = React.useRef(null);
30+
let listBoxRef = React.useRef(null);
31+
let popoverRef = React.useRef(null);
32+
33+
let {buttonProps: triggerProps, inputProps, listBoxProps, labelProps} = useComboBox(
34+
{
35+
...props,
36+
inputRef,
37+
buttonRef,
38+
listBoxRef,
39+
popoverRef
40+
},
41+
state
42+
);
43+
44+
// Call useButton to get props for the button element. Alternatively, you can
45+
// pass the triggerProps to a separate Button component using useButton
46+
// that you might already have in your component library.
47+
let {buttonProps} = useButton(triggerProps, buttonRef);
48+
49+
return (
50+
<div style={{display: 'inline-flex', flexDirection: 'column'}}>
51+
<label {...labelProps}>{props.label}</label>
52+
<div style={{position: 'relative', display: 'inline-block'}}>
53+
<input
54+
{...inputProps}
55+
ref={inputRef}
56+
style={{
57+
height: 24,
58+
boxSizing: 'border-box',
59+
marginRight: 0,
60+
fontSize: 16
61+
}} />
62+
<button
63+
{...buttonProps}
64+
ref={buttonRef}
65+
style={{
66+
height: 24,
67+
marginLeft: 0
68+
}}>
69+
<span
70+
aria-hidden="true"
71+
style={{padding: '0 2px'}}>
72+
73+
</span>
74+
</button>
75+
{state.isOpen &&
76+
<Popover popoverRef={popoverRef} isOpen={state.isOpen} onClose={state.close}>
77+
<ListBox
78+
{...listBoxProps}
79+
listBoxRef={listBoxRef}
80+
state={state} />
81+
</Popover>
82+
}
83+
</div>
84+
</div>
85+
);
86+
}
87+
88+
function Popover(props) {
89+
let ref = React.useRef();
90+
let {
91+
popoverRef = ref,
92+
isOpen,
93+
onClose,
94+
children
95+
} = props;
96+
97+
// Handle events that should cause the popup to close,
98+
// e.g. blur, clicking outside, or pressing the escape key.
99+
let {overlayProps} = useOverlay({
100+
isOpen,
101+
onClose,
102+
shouldCloseOnBlur: true,
103+
isDismissable: true
104+
}, popoverRef);
105+
106+
// Add a hidden <DismissButton> component at the end of the popover
107+
// to allow screen reader users to dismiss the popup easily.
108+
return (
109+
<FocusScope restoreFocus>
110+
<div
111+
{...overlayProps}
112+
ref={popoverRef}
113+
style={{
114+
position: 'absolute',
115+
width: '100%',
116+
border: '1px solid gray',
117+
background: 'lightgray',
118+
marginTop: 4
119+
}}>
120+
{children}
121+
<DismissButton onDismiss={onClose} />
122+
</div>
123+
</FocusScope>
124+
);
125+
}
126+
127+
128+
function ListBox(props) {
129+
let ref = React.useRef();
130+
let {listBoxRef = ref, state} = props;
131+
let {listBoxProps} = useListBox(props, state, listBoxRef);
132+
133+
return (
134+
<ul
135+
{...listBoxProps}
136+
ref={listBoxRef}
137+
style={{
138+
margin: 0,
139+
padding: 0,
140+
listStyle: 'none',
141+
maxHeight: '150px',
142+
overflow: 'auto'
143+
}}>
144+
{[...state.collection].map(item => (
145+
<Option
146+
key={item.key}
147+
item={item}
148+
state={state} />
149+
))}
150+
</ul>
151+
);
152+
}
153+
154+
function Option({item, state}) {
155+
let ref = React.useRef();
156+
let {optionProps, isSelected, isFocused, isDisabled} = useOption({key: item.key}, state, ref);
157+
158+
let backgroundColor;
159+
let color = 'black';
160+
161+
if (isSelected) {
162+
backgroundColor = 'blueviolet';
163+
color = 'white';
164+
} else if (isFocused) {
165+
backgroundColor = 'gray';
166+
} else if (isDisabled) {
167+
backgroundColor = 'transparent';
168+
color = 'gray';
169+
}
170+
171+
return (
172+
<li
173+
{...optionProps}
174+
ref={ref}
175+
style={{
176+
background: backgroundColor,
177+
color: color,
178+
padding: '2px 5px',
179+
outline: 'none',
180+
cursor: 'pointer'
181+
}}>
182+
{item.rendered}
183+
</li>
184+
);
185+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/*
2+
* Copyright 2021 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 {ComboBox} from './example';
14+
import {Item} from '@react-stately/collections';
15+
import React from 'react';
16+
17+
const meta = {
18+
title: 'useComboBox'
19+
};
20+
21+
export default meta;
22+
23+
let lotsOfItems: any[] = [];
24+
for (let i = 0; i < 50; i++) {
25+
lotsOfItems.push({name: 'Item ' + i});
26+
}
27+
28+
const Template = () => () => (
29+
<ComboBox label="Example" defaultItems={lotsOfItems}>
30+
{(item: any) => <Item key={item.name}>{item.name}</Item>}
31+
</ComboBox>
32+
);
33+
34+
export const ScrollTesting = Template().bind({});
35+
ScrollTesting.args = {};

packages/@react-aria/grid/src/useGrid.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ export interface GridProps extends DOMProps, AriaLabelingProps {
4040
* A function that returns the text that should be announced by assistive technology when a row is added or removed from selection.
4141
* @default (key) => state.collection.getItem(key)?.textValue
4242
*/
43-
getRowText?: (key: Key) => string
43+
getRowText?: (key: Key) => string,
44+
/**
45+
* The ref attached to the scrollable body. Used to provided automatic scrolling on item focus for non-virtualized grids.
46+
*/
47+
scrollRef?: RefObject<HTMLElement>
4448
}
4549

4650
export interface GridAria {
@@ -60,7 +64,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
6064
isVirtualized,
6165
keyboardDelegate,
6266
focusMode,
63-
getRowText = (key) => state.collection.getItem(key)?.textValue
67+
getRowText = (key) => state.collection.getItem(key)?.textValue,
68+
scrollRef
6469
} = props;
6570
let formatMessage = useMessageFormatter(intlMessages);
6671

@@ -83,7 +88,9 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
8388
let {collectionProps} = useSelectableCollection({
8489
ref,
8590
selectionManager: state.selectionManager,
86-
keyboardDelegate: delegate
91+
keyboardDelegate: delegate,
92+
isVirtualized,
93+
scrollRef
8794
});
8895

8996
let id = useId();

packages/@react-aria/select/docs/useSelect.mdx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,10 @@ In addition, see [useListBox](useListBox.html) for examples of sections (option
128128
options.
129129

130130
```tsx example export=true
131-
import {useSelectState} from '@react-stately/select';
131+
import {HiddenSelect, useSelect} from '@react-aria/select';
132132
import {Item} from '@react-stately/collections';
133-
import {useSelect, HiddenSelect} from '@react-aria/select';
134133
import {useButton} from '@react-aria/button';
134+
import {useSelectState} from '@react-stately/select';
135135

136136
// Reuse the ListBox and Popover from your component library. See below for details.
137137
import {ListBox, Popover} from 'your-component-library';
@@ -194,6 +194,10 @@ function Select(props) {
194194
<Item>Green</Item>
195195
<Item>Blue</Item>
196196
<Item>Purple</Item>
197+
<Item>Black</Item>
198+
<Item>White</Item>
199+
<Item>Lime</Item>
200+
<Item>Fushsia</Item>
197201
</Select>
198202
```
199203

@@ -208,7 +212,7 @@ See [useOverlayTrigger](useOverlayTrigger.html) for more examples of popovers.
208212
<summary style={{fontWeight: 'bold'}}><ChevronRight size="S" /> Show code</summary>
209213

210214
```tsx example export=true render=false
211-
import {useOverlay, DismissButton} from '@react-aria/overlays';
215+
import {DismissButton, useOverlay} from '@react-aria/overlays';
212216
import {FocusScope} from '@react-aria/focus';
213217

214218
function Popover(props) {
@@ -217,8 +221,7 @@ function Popover(props) {
217221
popoverRef = ref,
218222
isOpen,
219223
onClose,
220-
children,
221-
...otherProps
224+
children
222225
} = props;
223226

224227
// Handle events that should cause the popup to close,
@@ -245,7 +248,7 @@ function Popover(props) {
245248
marginTop: 4
246249
}}>
247250
{children}
248-
<DismissButton onDismiss={() => state.close()} />
251+
<DismissButton onDismiss={onClose} />
249252
</div>
250253
</FocusScope>
251254
);
@@ -278,7 +281,9 @@ function ListBox(props) {
278281
style={{
279282
margin: 0,
280283
padding: 0,
281-
listStyle: "none"
284+
listStyle: "none",
285+
maxHeight: "150px",
286+
overflow: "auto"
282287
}}>
283288
{[...state.collection].map(item => (
284289
<Option
@@ -287,7 +292,7 @@ function ListBox(props) {
287292
state={state} />
288293
))}
289294
</ul>
290-
)
295+
);
291296
}
292297

293298
function Option({item, state}) {

0 commit comments

Comments
 (0)