Skip to content

Commit 5716435

Browse files
LFDanLudannify
andauthored
CardView grid layout UX fixes + horizontal card support (#2481)
* preventing clicks/taps on focusable elements from scrolling virtualized lists * adding transitions on card removal + load to CardView * rough support for horizontal card orientation in grid layout * moving card view styles into spectrum-css and removing blue outline from clicking on card * updating story format to csf as per review * modifying default size of horizontal cards + removing selection mode for horizontal * adding scale to layouts and shifting default itemPadding values to layouts * fixing grid layout isVisible check adding the transition duration broke async loading and toggling items stories in grid layout card view. Turns out that layoutInfo is not always defined in the isVisible check so accounting for that case * fixing scroll when clicking on action menu trigger in card view Turns out that opening the action menu in a card view triggers useVirtualizers onFocus when focusSafely is called on the actionmenu "ul". Added a extra conditional to stop useVirtualizer from scrolling to the focused key if focus target is happening on an element outside the virtualizer. * fixing tests * cleanup * adding test for horizontal cards in cardview * only calling isVisible in GridLayout if layoutInfo is available * updating quiet vertical cards in grid cardview so that text content is single line updates the grid layout calc so the content section is always single line height. The rest of the card then expands to fill the available vertical height to accomodate layouts that provide item padding values much greater than what is required to display the single line of text * fixing horizontal card image aspect ratio resizing previously we calculated the correct aspect ratio for the image for a horizontal card in a useLayoutEffect but this didnt quite work when the CardView changed in size. Now we calculate the aspect ratio during resize via useResizeObserver * removing !important styles for cardview and moving them to layoutInfo * fixing shift tab behavior * adding offset to scrollToView for cardview so that focus rings are not cut off * updating scrollToItem in CardView to use margin from layout * removing some design specific TODOs and moving into issues * removing another TODO thought again about this again, we need cardOrientation in the CardViewProps in case the user passes in the layout constructor instead of a layout * updating type description for gridlayout * addressing review comments Co-authored-by: Danni <[email protected]>
1 parent 0ddb7af commit 5716435

File tree

19 files changed

+435
-232
lines changed

19 files changed

+435
-232
lines changed

packages/@adobe/spectrum-css-temp/components/card/index.css

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ user-provided
828828
block-size: 100%;
829829
display: grid;
830830
grid-template-columns: minmax(0, auto) minmax(30px, 1fr) minmax(0, auto);
831-
grid-template-rows: auto var(--spectrum-card-quiet-body-header-margin-top) minmax(var(--spectrum-actionbutton-height), auto) 1fr var(--spectrum-card-body-padding-bottom);
831+
grid-template-rows: auto var(--spectrum-card-quiet-body-header-margin-top) minmax(var(--spectrum-actionbutton-height), auto) var(--spectrum-alias-single-line-height) 1fr;
832832
grid-template-areas:
833833
"preview preview preview"
834834
". . ."
@@ -1241,3 +1241,20 @@ For now working around it with a useLayoutEffect in CardBase.
12411241
}
12421242
}
12431243
/** END Standard Horizontal **/
1244+
1245+
/* CardView styles */
1246+
.spectrum-CardView {
1247+
outline: none;
1248+
1249+
.spectrum-CardView-centeredWrapper {
1250+
display: flex;
1251+
align-items: center;
1252+
justify-content: center;
1253+
width: 100%;
1254+
height: 100%;
1255+
}
1256+
.spectrum-CardView-row {
1257+
outline: none;
1258+
height: 100%
1259+
}
1260+
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"dependencies": {
2020
"@babel/runtime": "^7.6.2",
2121
"@react-aria/i18n": "^3.3.2",
22+
"@react-aria/interactions": "^3.6.0",
2223
"@react-aria/utils": "^3.8.2",
2324
"@react-stately/virtualizer": "^3.1.5",
2425
"@react-types/shared": "^3.8.0"

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import {Collection} from '@react-types/shared';
1414
import {focusWithoutScrolling, mergeProps, useLayoutEffect} from '@react-aria/utils';
15+
import {getInteractionModality} from '@react-aria/interactions';
1516
import {Layout, Rect, ReusableView, useVirtualizerState, VirtualizerState} from '@react-stately/virtualizer';
1617
import React, {FocusEvent, HTMLAttributes, Key, ReactElement, RefObject, useCallback, useEffect, useRef} from 'react';
1718
import {ScrollView} from './ScrollView';
@@ -33,7 +34,8 @@ interface VirtualizerProps<T extends object, V> extends HTMLAttributes<HTMLEleme
3334
transitionDuration?: number,
3435
isLoading?: boolean,
3536
onLoadMore?: () => void,
36-
shouldUseVirtualFocus?: boolean
37+
shouldUseVirtualFocus?: boolean,
38+
scrollToItem?: (key: Key) => void
3739
}
3840

3941
function Virtualizer<T extends object, V>(props: VirtualizerProps<T, V>, ref: RefObject<HTMLDivElement>) {
@@ -51,6 +53,8 @@ function Virtualizer<T extends object, V>(props: VirtualizerProps<T, V>, ref: Re
5153
focusedKey,
5254
// eslint-disable-next-line @typescript-eslint/no-unused-vars
5355
shouldUseVirtualFocus,
56+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
57+
scrollToItem,
5458
...otherProps
5559
} = props;
5660

@@ -125,8 +129,8 @@ export function useVirtualizer<T extends object, V, W>(props: VirtualizerOptions
125129
if (virtualizer.visibleRect.height === 0) {
126130
return;
127131
}
128-
129-
if (focusedKey !== lastFocusedKey.current) {
132+
let modality = getInteractionModality();
133+
if (focusedKey !== lastFocusedKey.current && modality !== 'pointer') {
130134
if (scrollToItem) {
131135
scrollToItem(focusedKey);
132136
} else {
@@ -141,7 +145,8 @@ export function useVirtualizer<T extends object, V, W>(props: VirtualizerOptions
141145
let onFocus = useCallback((e: FocusEvent) => {
142146
// If the focused item is scrolled out of view and is not in the DOM, the collection
143147
// will have tabIndex={0}. When tabbing in from outside, scroll the focused item into view.
144-
if (!isFocusWithin.current) {
148+
// Ignore focus events that bubble through portals (e.g. focus that happens on a menu popover child of the virtualizer)
149+
if (!isFocusWithin.current && ref.current.contains(e.target)) {
145150
if (scrollToItem) {
146151
scrollToItem(focusedKey);
147152
} else {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export function layoutInfoToStyle(layoutInfo: LayoutInfo, dir: Direction, parent
5757

5858
let style: CSSProperties = {
5959
position: layoutInfo.isSticky ? 'sticky' : 'absolute',
60-
overflow: 'hidden',
60+
overflow: layoutInfo.allowOverflow ? 'visible' : 'hidden',
6161
top: layoutInfo.rect.y - (parent ? parent.rect.y : 0),
6262
[xProperty]: layoutInfo.rect.x - (parent ? parent.rect.x : 0),
6363
transition: 'all',
@@ -69,7 +69,7 @@ export function layoutInfoToStyle(layoutInfo: LayoutInfo, dir: Direction, parent
6969
opacity: layoutInfo.opacity,
7070
zIndex: layoutInfo.zIndex,
7171
transform: layoutInfo.transform,
72-
contain: 'size layout style paint'
72+
contain: layoutInfo.allowOverflow ? 'size layout style' : 'size layout style paint'
7373
};
7474

7575
cache.set(layoutInfo, style);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@react-stately/utils": "^3.2.1",
5050
"@react-stately/virtualizer": "^3.1.5",
5151
"@react-types/card": "3.0.0-alpha.0",
52+
"@react-types/provider": "^3.3.2",
5253
"@react-types/shared": "^3.0.0"
5354
},
5455
"devDependencies": {

packages/@react-spectrum/card/src/BaseLayout.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,17 @@ import {Direction, KeyboardDelegate, Node} from '@react-types/shared';
1414
import {GridCollection} from '@react-stately/grid';
1515
import {InvalidationContext, Layout, LayoutInfo, Rect, Size} from '@react-stately/virtualizer';
1616
import {Key} from 'react';
17+
import {Scale} from '@react-types/provider';
1718

1819
export interface BaseLayoutOptions {
19-
collator?: Intl.Collator
20+
collator?: Intl.Collator,
21+
// TODO: is this valid or is scale a spectrum specific thing that should be left out of the layouts?
22+
scale?: Scale,
23+
/**
24+
* The margin around the grid view between the edges and the items.
25+
* @default 24
26+
*/
27+
margin?: number
2028
}
2129

2230
export class BaseLayout<T> extends Layout<Node<T>> implements KeyboardDelegate {
@@ -28,12 +36,16 @@ export class BaseLayout<T> extends Layout<Node<T>> implements KeyboardDelegate {
2836
isLoading: boolean;
2937
disabledKeys: Set<Key> = new Set();
3038
direction: Direction;
39+
scale: Scale;
40+
margin: number;
3141

3242
constructor(options: BaseLayoutOptions = {}) {
3343
super();
3444
this.layoutInfos = new Map();
3545
this.collator = options.collator;
3646
this.lastCollection = null;
47+
this.scale = options.scale || 'medium';
48+
this.margin = options.margin || 24;
3749
}
3850

3951
validate(invalidationContext: InvalidationContext<Node<T>, unknown>) {

packages/@react-spectrum/card/src/CardBase.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {Checkbox} from '@react-spectrum/checkbox';
1515
import {classNames, SlotProvider, useDOMRef, useHasChild, useStyleProps} from '@react-spectrum/utils';
1616
import {Divider} from '@react-spectrum/divider';
1717
import {DOMRef, Node} from '@react-types/shared';
18-
import {filterDOMProps, mergeProps, useLayoutEffect, useSlotId} from '@react-aria/utils';
18+
import {filterDOMProps, mergeProps, useLayoutEffect, useResizeObserver, useSlotId} from '@react-aria/utils';
1919
import {FocusRing} from '@react-aria/focus';
20-
import React, {HTMLAttributes, useMemo, useRef, useState} from 'react';
20+
import React, {HTMLAttributes, useCallback, useMemo, useRef, useState} from 'react';
2121
import styles from '@adobe/spectrum-css-temp/components/card/vars.css';
2222
import {useCardViewContext} from './CardViewContext';
2323
import {useFocusWithin, useHover} from '@react-aria/interactions';
@@ -30,7 +30,6 @@ interface CardBaseProps<T> extends SpectrumCardProps {
3030

3131
function CardBase<T extends object>(props: CardBaseProps<T>, ref: DOMRef<HTMLDivElement>) {
3232
props = useProviderProps(props);
33-
// TODO: Don't send in articleProps via context (unless we want to make another context for InternalCard)? Pass it in via props since it will only be provided via CardView's InternalCard
3433
let context = useCardViewContext() || {}; // we can call again here, won't change from Card.tsx
3534
let {state} = context;
3635
let manager = state?.selectionManager;
@@ -69,13 +68,16 @@ function CardBase<T extends object>(props: CardBaseProps<T>, ref: DOMRef<HTMLDiv
6968

7069
// this is for horizontal cards
7170
let [height, setHeight] = useState(NaN);
72-
useLayoutEffect(() => {
71+
let updateHeight = useCallback(() => {
7372
if (orientation !== 'horizontal') {
7473
return;
7574
}
75+
7676
let cardHeight = gridRef.current.getBoundingClientRect().height;
7777
setHeight(cardHeight);
78-
}, [gridRef, setHeight, orientation]);
78+
}, [orientation, gridRef, setHeight]);
79+
useResizeObserver({ref: gridRef, onResize: updateHeight});
80+
7981
let aspectRatioEnforce = undefined;
8082
if (orientation === 'horizontal' && !isNaN(height)) {
8183
aspectRatioEnforce = {

packages/@react-spectrum/card/src/CardView.tsx

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,16 @@
1212

1313
import {CardBase} from './CardBase';
1414
import {CardViewContext, useCardViewContext} from './CardViewContext';
15-
import cardViewStyles from './cardview.css';
1615
import {classNames, useDOMRef, useStyleProps, useUnwrapDOMRef} from '@react-spectrum/utils';
1716
import {DOMRef, DOMRefValue, Node} from '@react-types/shared';
1817
import {GridCollection, useGridState} from '@react-stately/grid';
1918
// @ts-ignore
2019
import intlMessages from '../intl/*.json';
2120
import {ProgressCircle} from '@react-spectrum/progress';
22-
import React, {ReactElement, useMemo, useRef} from 'react';
21+
import React, {ReactElement, useCallback, useMemo, useRef} from 'react';
2322
import {ReusableView} from '@react-stately/virtualizer';
2423
import {SpectrumCardViewProps} from '@react-types/card';
24+
import styles from '@adobe/spectrum-css-temp/components/card/vars.css';
2525
import {useCollator, useLocale, useMessageFormatter} from '@react-aria/i18n';
2626
import {useGrid, useGridCell, useGridRow} from '@react-aria/grid';
2727
import {useListState} from '@react-stately/list';
@@ -37,21 +37,15 @@ function CardView<T extends object>(props: SpectrumCardViewProps<T>, ref: DOMRef
3737
renderEmptyState,
3838
layout,
3939
loadingState,
40-
onLoadMore
40+
onLoadMore,
41+
cardOrientation = 'vertical'
4142
} = props;
43+
4244
let collator = useCollator({usage: 'search', sensitivity: 'base'});
4345
let isLoading = loadingState === 'loading' || loadingState === 'loadingMore';
44-
let cardViewLayout = useMemo(() => typeof layout === 'function' ? new layout({collator}) : layout, [layout, collator]);
46+
let cardViewLayout = useMemo(() => typeof layout === 'function' ? new layout({collator, cardOrientation, scale}) : layout, [layout, collator, cardOrientation, scale]);
4547
let layoutType = cardViewLayout.layoutType;
4648

47-
if (typeof layout === 'function') {
48-
if (layoutType === 'grid') {
49-
cardViewLayout.itemPadding = scale === 'large' ? 116 : 95;
50-
} else if (layoutType === 'gallery') {
51-
cardViewLayout.itemPadding = scale === 'large' ? 143 : 114;
52-
}
53-
}
54-
5549
let formatMessage = useMessageFormatter(intlMessages);
5650
let {direction} = useLocale();
5751
let {collection} = useListState(props);
@@ -77,6 +71,7 @@ function CardView<T extends object>(props: SpectrumCardViewProps<T>, ref: DOMRef
7771

7872
let state = useGridState({
7973
...props,
74+
selectionMode: cardOrientation === 'horizontal' && layoutType === 'grid' ? 'none' : props.selectionMode,
8075
collection: gridCollection,
8176
focusMode: 'cell'
8277
});
@@ -95,7 +90,6 @@ function CardView<T extends object>(props: SpectrumCardViewProps<T>, ref: DOMRef
9590
type View = ReusableView<Node<T>, unknown>;
9691
let renderWrapper = (parent: View, reusableView: View) => (
9792
<VirtualizerItem
98-
className={classNames(cardViewStyles, 'react-spectrum-CardView-CardWrapper')}
9993
key={reusableView.key}
10094
reusableView={reusableView}
10195
parent={parent} />
@@ -107,21 +101,32 @@ function CardView<T extends object>(props: SpectrumCardViewProps<T>, ref: DOMRef
107101
focusedKey = focusedItem.parentKey;
108102
}
109103

104+
let margin = cardViewLayout.margin || 0;
105+
let virtualizer = cardViewLayout.virtualizer;
106+
let scrollToItem = useCallback((key) => {
107+
virtualizer && virtualizer.scrollToItem(key, {
108+
duration: 0,
109+
offsetY: margin
110+
});
111+
}, [margin, virtualizer]);
112+
110113
// TODO: does aria-row count and aria-col count need to be modified? Perhaps aria-col count needs to be omitted
111114
return (
112-
<CardViewContext.Provider value={{state, isQuiet, layout: cardViewLayout}}>
115+
<CardViewContext.Provider value={{state, isQuiet, layout: cardViewLayout, cardOrientation}}>
113116
<Virtualizer
114117
{...gridProps}
115118
{...styleProps}
116-
className={classNames(cardViewStyles, 'react-spectrum-CardView')}
119+
className={classNames(styles, 'spectrum-CardView')}
117120
ref={domRef}
118121
focusedKey={focusedKey}
119122
scrollDirection="vertical"
120123
layout={cardViewLayout}
121124
collection={gridCollection}
122125
isLoading={isLoading}
123126
onLoadMore={onLoadMore}
124-
renderWrapper={renderWrapper}>
127+
renderWrapper={renderWrapper}
128+
transitionDuration={isLoading ? 160 : 220}
129+
scrollToItem={scrollToItem}>
125130
{(type, item) => {
126131
if (type === 'item') {
127132
return (
@@ -160,7 +165,7 @@ function CenteredWrapper({children}) {
160165
<div
161166
role="row"
162167
aria-rowindex={state.collection.size + 1}
163-
className={classNames(cardViewStyles, 'react-spectrum-CardView-centeredWrapper')}>
168+
className={classNames(styles, 'spectrum-CardView-centeredWrapper')}>
164169
<div role="gridcell">
165170
{children}
166171
</div>
@@ -195,8 +200,12 @@ function InternalCard(props) {
195200
isQuiet = true;
196201
}
197202

203+
if (layoutType !== 'grid') {
204+
cardOrientation = 'vertical';
205+
}
206+
198207
return (
199-
<div {...rowProps} ref={rowRef} style={{height: '100%'}}>
208+
<div {...rowProps} ref={rowRef} className={classNames(styles, 'spectrum-CardView-row')}>
200209
<CardBase
201210
ref={cellRef}
202211
articleProps={gridCellProps}

packages/@react-spectrum/card/src/GalleryLayout.tsx

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,14 @@ export interface GalleryLayoutOptions extends BaseLayoutOptions {
4343
* will be targeted.
4444
* @type {number}
4545
*/
46-
threshold?: number,
47-
/**
48-
* The margin around the grid view between the edges and the items.
49-
* @default 24
50-
*/
51-
margin?: number // TODO: Perhaps should accept Responsive<DimensionValue>
46+
threshold?: number
5247
}
5348

54-
// TODO: copied from V2, update this with the proper spectrum values
55-
// Should these be affected by Scale as well?
5649
const DEFAULT_OPTIONS = {
5750
S: {
5851
idealRowHeight: 112,
5952
minItemSize: new Size(96, 96),
6053
itemSpacing: new Size(8, 16),
61-
// TODO: will need to update as well
6254
itemPadding: 24,
6355
dropSpacing: 50,
6456
margin: 8
@@ -67,28 +59,28 @@ const DEFAULT_OPTIONS = {
6759
idealRowHeight: 208,
6860
minItemSize: new Size(136, 136),
6961
itemSpacing: new Size(18, 18),
70-
// TODO: updated to work with new v3 cards (there is additional space required for the descriptions if there is a description)
71-
itemPadding: 114,
62+
itemPadding: {
63+
'medium': 114,
64+
'large': 143
65+
},
7266
dropSpacing: 100,
7367
margin: 24
7468
}
7569
};
7670

7771
export class GalleryLayout<T> extends BaseLayout<T> {
7872
protected idealRowHeight: number;
79-
protected margin: number;
8073
protected itemSpacing: Size;
8174
itemPadding: number;
8275
protected minItemSize: Size;
8376
protected threshold: number;
8477

8578
constructor(options: GalleryLayoutOptions = {}) {
8679
super(options);
87-
// TODO: restore cardSize option when we support different size cards
8880
let cardSize = 'L';
8981
this.idealRowHeight = options.idealRowHeight || DEFAULT_OPTIONS[cardSize].idealRowHeight;
9082
this.itemSpacing = options.itemSpacing || DEFAULT_OPTIONS[cardSize].itemSpacing;
91-
this.itemPadding = options.itemPadding != null ? options.itemPadding : DEFAULT_OPTIONS[cardSize].itemPadding;
83+
this.itemPadding = options.itemPadding != null ? options.itemPadding : DEFAULT_OPTIONS[cardSize].itemPadding[this.scale];
9284
this.minItemSize = options.minItemSize || DEFAULT_OPTIONS[cardSize].minItemSize;
9385
this.threshold = options.threshold || 1;
9486
this.margin = options.margin != null ? options.margin : DEFAULT_OPTIONS[cardSize].margin;
@@ -213,6 +205,7 @@ export class GalleryLayout<T> extends BaseLayout<T> {
213205
let itemWidth = Math.max(widths[j - index][1], this.minItemSize.width);
214206
let rect = new Rect(x, y, itemWidth, itemHeight);
215207
let layoutInfo = new LayoutInfo(node.type, node.key, rect);
208+
layoutInfo.allowOverflow = true;
216209
this.layoutInfos.set(node.key, layoutInfo);
217210
x += itemWidth + this.itemSpacing.width;
218211
}

0 commit comments

Comments
 (0)