Skip to content

Commit 8ab4f20

Browse files
authored
S2 CardView and ghost loading (#6978)
1 parent 086ad31 commit 8ab4f20

Some content is hidden

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

43 files changed

+2696
-257
lines changed

.eslintrc.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ module.exports = {
120120
'AsyncIterable': 'readonly',
121121
'FileSystemFileEntry': 'readonly',
122122
'FileSystemDirectoryEntry': 'readonly',
123-
'FileSystemEntry': 'readonly'
123+
'FileSystemEntry': 'readonly',
124+
'IS_REACT_ACT_ENVIRONMENT': 'readonly'
124125
},
125126
settings: {
126127
jsdoc: {

packages/@react-aria/selection/src/ListKeyboardDelegate.ts

+26-35
Original file line numberDiff line numberDiff line change
@@ -74,32 +74,27 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
7474
return this.disabledBehavior === 'all' && (item.props?.isDisabled || this.disabledKeys.has(item.key));
7575
}
7676

77-
getNextKey(key: Key) {
78-
key = this.collection.getKeyAfter(key);
77+
private findNextNonDisabled(key: Key, getNext: (key: Key) => Key | null): Key | null {
7978
while (key != null) {
8079
let item = this.collection.getItem(key);
81-
if (item.type === 'item' && !this.isDisabled(item)) {
80+
if (item?.type === 'item' && !this.isDisabled(item)) {
8281
return key;
8382
}
8483

85-
key = this.collection.getKeyAfter(key);
84+
key = getNext(key);
8685
}
8786

8887
return null;
8988
}
9089

90+
getNextKey(key: Key) {
91+
key = this.collection.getKeyAfter(key);
92+
return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key));
93+
}
94+
9195
getPreviousKey(key: Key) {
9296
key = this.collection.getKeyBefore(key);
93-
while (key != null) {
94-
let item = this.collection.getItem(key);
95-
if (item.type === 'item' && !this.isDisabled(item)) {
96-
return key;
97-
}
98-
99-
key = this.collection.getKeyBefore(key);
100-
}
101-
102-
return null;
97+
return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key));
10398
}
10499

105100
private findKey(
@@ -151,6 +146,14 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
151146
}
152147

153148
getKeyRightOf(key: Key) {
149+
// This is a temporary solution for CardView until we refactor useSelectableCollection.
150+
// https://github.com/orgs/adobe/projects/19/views/32?pane=issue&itemId=77825042
151+
let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyRightOf' : 'getKeyLeftOf';
152+
if (this.layoutDelegate[layoutDelegateMethod]) {
153+
key = this.layoutDelegate[layoutDelegateMethod](key);
154+
return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key));
155+
}
156+
154157
if (this.layout === 'grid') {
155158
if (this.orientation === 'vertical') {
156159
return this.getNextColumn(key, this.direction === 'rtl');
@@ -165,6 +168,12 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
165168
}
166169

167170
getKeyLeftOf(key: Key) {
171+
let layoutDelegateMethod = this.direction === 'ltr' ? 'getKeyLeftOf' : 'getKeyRightOf';
172+
if (this.layoutDelegate[layoutDelegateMethod]) {
173+
key = this.layoutDelegate[layoutDelegateMethod](key);
174+
return this.findNextNonDisabled(key, key => this.layoutDelegate[layoutDelegateMethod](key));
175+
}
176+
168177
if (this.layout === 'grid') {
169178
if (this.orientation === 'vertical') {
170179
return this.getNextColumn(key, this.direction === 'ltr');
@@ -180,30 +189,12 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
180189

181190
getFirstKey() {
182191
let key = this.collection.getFirstKey();
183-
while (key != null) {
184-
let item = this.collection.getItem(key);
185-
if (item?.type === 'item' && !this.isDisabled(item)) {
186-
return key;
187-
}
188-
189-
key = this.collection.getKeyAfter(key);
190-
}
191-
192-
return null;
192+
return this.findNextNonDisabled(key, key => this.collection.getKeyAfter(key));
193193
}
194194

195195
getLastKey() {
196196
let key = this.collection.getLastKey();
197-
while (key != null) {
198-
let item = this.collection.getItem(key);
199-
if (item.type === 'item' && !this.isDisabled(item)) {
200-
return key;
201-
}
202-
203-
key = this.collection.getKeyBefore(key);
204-
}
205-
206-
return null;
197+
return this.findNextNonDisabled(key, key => this.collection.getKeyBefore(key));
207198
}
208199

209200
getKeyPageAbove(key: Key) {
@@ -280,7 +271,7 @@ export class ListKeyboardDelegate<T> implements KeyboardDelegate {
280271
return key;
281272
}
282273

283-
key = this.getKeyBelow(key);
274+
key = this.getNextKey(key);
284275
}
285276

286277
return null;

packages/@react-aria/utils/src/useLoadMore.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export interface LoadMoreProps {
2929
*/
3030
scrollOffset?: number,
3131
/** The data currently loaded. */
32-
items?: any[]
32+
items?: any
3333
}
3434

3535
export function useLoadMore(props: LoadMoreProps, ref: RefObject<HTMLElement | null>) {

packages/@react-aria/virtualizer/src/ScrollView.tsx

+26-17
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,17 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
143143
// eslint-disable-next-line react-hooks/exhaustive-deps
144144
}, []);
145145

146+
let isUpdatingSize = useRef(false);
146147
let updateSize = useEffectEvent((flush: typeof flushSync) => {
147148
let dom = ref.current;
148-
if (!dom) {
149+
if (!dom && !isUpdatingSize.current) {
149150
return;
150151
}
151152

153+
// Prevent reentrancy when resize observer fires, triggers re-layout that results in
154+
// content size update, causing below layout effect to fire. This avoids infinite loops.
155+
isUpdatingSize.current = true;
156+
152157
let isTestEnv = process.env.NODE_ENV === 'test' && !process.env.VIRT_ON;
153158
let isClientWidthMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientWidth');
154159
let isClientHeightMocked = Object.getOwnPropertyNames(window.HTMLElement.prototype).includes('clientHeight');
@@ -177,27 +182,31 @@ export function useScrollView(props: ScrollViewProps, ref: RefObject<HTMLElement
177182
});
178183
}
179184
}
185+
186+
isUpdatingSize.current = false;
180187
});
181188

182-
let didUpdateSize = useRef(false);
189+
// Update visible rect when the content size changes, in case scrollbars need to appear or disappear.
190+
let lastContentSize = useRef<Size | null>(null);
183191
useLayoutEffect(() => {
184-
// React doesn't allow flushSync inside effects, so queue a microtask.
185-
// We also need to wait until all refs are set (e.g. when passing a ref down from a parent).
186-
queueMicrotask(() => {
187-
if (!didUpdateSize.current) {
188-
didUpdateSize.current = true;
189-
updateSize(flushSync);
192+
if (!isUpdatingSize.current && (lastContentSize.current == null || !contentSize.equals(lastContentSize.current))) {
193+
// React doesn't allow flushSync inside effects, so queue a microtask.
194+
// We also need to wait until all refs are set (e.g. when passing a ref down from a parent).
195+
// If we are in an `act` environment, update immediately without a microtask so you don't need
196+
// to mock timers in tests. In this case, the update is synchronous already.
197+
// IS_REACT_ACT_ENVIRONMENT is used by React 18. Previous versions checked for the `jest` global.
198+
// https://github.com/reactwg/react-18/discussions/102
199+
// @ts-ignore
200+
if (typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined') {
201+
updateSize(fn => fn());
202+
} else {
203+
queueMicrotask(() => updateSize(flushSync));
190204
}
191-
});
192-
}, [updateSize]);
193-
useEffect(() => {
194-
if (!didUpdateSize.current) {
195-
// If useEffect ran before the above microtask, we are in a synchronous render (e.g. act).
196-
// Update the size here so that you don't need to mock timers in tests.
197-
didUpdateSize.current = true;
198-
updateSize(fn => fn());
199205
}
200-
}, [updateSize]);
206+
207+
lastContentSize.current = contentSize;
208+
});
209+
201210
let onResize = useCallback(() => {
202211
updateSize(flushSync);
203212
}, [updateSize]);

packages/@react-spectrum/s2/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"@react-aria/interactions": "^3.22.2",
130130
"@react-aria/utils": "^3.25.2",
131131
"@react-spectrum/utils": "^3.11.10",
132+
"@react-stately/virtualizer": "^4.0.1",
132133
"@react-types/color": "3.0.0-rc.1",
133134
"@react-types/dialog": "^3.5.8",
134135
"@react-types/provider": "^3.7.2",

packages/@react-spectrum/s2/src/ActionButton.tsx

+6-2
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,17 @@
1111
*/
1212

1313
import {baseColor, fontRelative, style} from '../style/spectrum-theme' with { type: 'macro' };
14-
import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton} from 'react-aria-components';
14+
import {ButtonProps, ButtonRenderProps, ContextValue, OverlayTriggerStateContext, Provider, Button as RACButton, Text} from 'react-aria-components';
1515
import {centerBaseline} from './CenterBaseline';
1616
import {createContext, forwardRef, ReactNode, useContext} from 'react';
1717
import {FocusableRef, FocusableRefValue} from '@react-types/shared';
1818
import {focusRing, getAllowedOverrides, StyleProps} from './style-utils' with { type: 'macro' };
1919
import {IconContext} from './Icon';
2020
import {pressScale} from './pressScale';
21-
import {Text, TextContext} from './Content';
21+
import {SkeletonContext} from './Skeleton';
22+
import {TextContext} from './Content';
2223
import {useFocusableRef} from '@react-spectrum/utils';
24+
import {useFormProps} from './Form';
2325
import {useSpectrumContextProps} from './useSpectrumContextProps';
2426

2527
export interface ActionButtonStyleProps {
@@ -175,6 +177,7 @@ export const ActionButtonContext = createContext<ContextValue<ActionButtonProps,
175177

176178
function ActionButton(props: ActionButtonProps, ref: FocusableRef<HTMLButtonElement>) {
177179
[props, ref] = useSpectrumContextProps(props, ref, ActionButtonContext);
180+
props = useFormProps(props as any);
178181
let domRef = useFocusableRef(ref);
179182
let overlayTriggerState = useContext(OverlayTriggerStateContext);
180183

@@ -193,6 +196,7 @@ function ActionButton(props: ActionButtonProps, ref: FocusableRef<HTMLButtonElem
193196
}, props.styles)}>
194197
<Provider
195198
values={[
199+
[SkeletonContext, null],
196200
[TextContext, {styles: style({paddingY: '--labelPadding', order: 1, truncate: true})}],
197201
[IconContext, {
198202
render: centerBaseline({slot: 'icon', styles: style({order: 0})}),

packages/@react-spectrum/s2/src/ActionMenu.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ import {useSpectrumContextProps} from './useSpectrumContextProps';
2626

2727
export interface ActionMenuProps<T> extends
2828
Pick<MenuTriggerProps, 'isOpen' | 'defaultOpen' | 'onOpenChange' | 'align' | 'direction' | 'shouldFlip'>,
29-
Pick<MenuProps<T>, 'children' | 'items' | 'disabledKeys' | 'onAction' | 'size'>,
30-
Pick<ActionButtonProps, 'isDisabled' | 'isQuiet' | 'autoFocus'>,
29+
Pick<MenuProps<T>, 'children' | 'items' | 'disabledKeys' | 'onAction'>,
30+
Pick<ActionButtonProps, 'isDisabled' | 'isQuiet' | 'autoFocus' | 'size'>,
3131
StyleProps, DOMProps, AriaLabelingProps {
32-
}
32+
menuSize?: 'S' | 'M' | 'L' | 'XL'
33+
}
3334

3435
export const ActionMenuContext = createContext<ContextValue<ActionMenuProps<any>, FocusableRefValue<HTMLButtonElement>>>(null);
3536

@@ -41,7 +42,6 @@ function ActionMenu<T extends object>(props: ActionMenuProps<T>, ref: FocusableR
4142
buttonProps['aria-label'] = stringFormatter.format('menu.moreActions');
4243
}
4344

44-
// size independently controlled?
4545
return (
4646
<MenuTrigger
4747
isOpen={props.isOpen}
@@ -52,19 +52,19 @@ function ActionMenu<T extends object>(props: ActionMenuProps<T>, ref: FocusableR
5252
shouldFlip={props.shouldFlip}>
5353
<ActionButton
5454
ref={ref}
55-
aria-label="Help"
5655
size={props.size}
5756
isDisabled={props.isDisabled}
5857
autoFocus={props.autoFocus}
5958
isQuiet={props.isQuiet}
59+
styles={props.styles}
6060
{...buttonProps}>
6161
<MoreIcon />
6262
</ActionButton>
6363
<Menu
6464
items={props.items}
6565
disabledKeys={props.disabledKeys}
6666
onAction={props.onAction}
67-
size={props.size}>
67+
size={props.menuSize}>
6868
{/* @ts-ignore TODO: fix type, right now this component is the same as Menu */}
6969
{props.children}
7070
</Menu>

packages/@react-spectrum/s2/src/Avatar.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {createContext, forwardRef} from 'react';
1515
import {DOMProps, DOMRef, DOMRefValue} from '@react-types/shared';
1616
import {filterDOMProps} from '@react-aria/utils';
1717
import {getAllowedOverrides, StylesPropWithoutWidth, UnsafeStyles} from './style-utils' with {type: 'macro'};
18+
import {Image} from './Image';
1819
import {style} from '../style/spectrum-theme' with { type: 'macro' };
1920
import {useDOMRef} from '@react-spectrum/utils';
2021
import {useSpectrumContextProps} from './useSpectrumContextProps';
@@ -71,16 +72,17 @@ function Avatar(props: AvatarProps, ref: DOMRef<HTMLImageElement>) {
7172
let remSize = size / 16 + 'rem';
7273
let isLarge = size >= 64;
7374
return (
74-
<img
75+
<Image
7576
{...domProps}
7677
ref={domRef}
7778
alt={alt}
78-
style={{
79+
UNSAFE_style={{
7980
...UNSAFE_style,
8081
width: remSize,
8182
height: remSize
8283
}}
83-
className={(UNSAFE_className ?? '') + imageStyles({isOverBackground, isLarge}, props.styles)}
84+
UNSAFE_className={UNSAFE_className}
85+
styles={imageStyles({isOverBackground, isLarge}, props.styles)}
8486
src={src} />
8587
);
8688
}

packages/@react-spectrum/s2/src/Badge.tsx

+17-13
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,21 @@ import {filterDOMProps} from '@react-aria/utils';
1818
import {fontRelative, style} from '../style/spectrum-theme' with {type: 'macro'};
1919
import {IconContext} from './Icon';
2020
import React, {createContext, forwardRef, ReactNode} from 'react';
21+
import {SkeletonWrapper} from './Skeleton';
2122
import {Text, TextContext} from './Content';
2223
import {useDOMRef} from '@react-spectrum/utils';
2324
import {useSpectrumContextProps} from './useSpectrumContextProps';
2425

2526
export interface BadgeStyleProps {
2627
/**
2728
* The size of the badge.
28-
*
29+
*
2930
* @default 'S'
3031
*/
3132
size?: 'S' | 'M' | 'L' | 'XL',
3233
/**
3334
* The variant changes the background color of the badge. When badge has a semantic meaning, they should use the variant for semantic colors.
34-
*
35+
*
3536
* @default 'neutral'
3637
*/
3738
variant?: 'accent' | 'informative' | 'neutral' | 'positive' | 'notice' | 'negative' | 'gray' | 'red' | 'orange' | 'yellow' | 'charteuse' | 'celery' | 'green' | 'seafoam' | 'cyan' | 'blue' | 'indigo' | 'purple' | 'fuchsia' | 'magenta' | 'pink' | 'turquoise' | 'brown' | 'cinnamon' | 'silver',
@@ -201,17 +202,20 @@ function Badge(props: BadgeProps, ref: DOMRef<HTMLDivElement>) {
201202
styles: style({size: fontRelative(20), marginStart: '--iconMargin', flexShrink: 0})
202203
}]
203204
]}>
204-
<span
205-
{...filterDOMProps(otherProps)}
206-
role="presentation"
207-
className={(props.UNSAFE_className || '') + badge({variant, size, fillStyle}, props.styles)}
208-
ref={domRef}>
209-
{
210-
typeof children === 'string' || isTextOnly
211-
? <Text>{children}</Text>
212-
: children
213-
}
214-
</span>
205+
<SkeletonWrapper>
206+
<span
207+
{...filterDOMProps(otherProps)}
208+
role="presentation"
209+
className={(props.UNSAFE_className || '') + badge({variant, size, fillStyle}, props.styles)}
210+
style={props.UNSAFE_style}
211+
ref={domRef}>
212+
{
213+
typeof children === 'string' || isTextOnly
214+
? <Text>{children}</Text>
215+
: children
216+
}
217+
</span>
218+
</SkeletonWrapper>
215219
</Provider>
216220
);
217221
}

0 commit comments

Comments
 (0)