Skip to content

Commit bd15da6

Browse files
committed
Merge branch 'main' of github.com:adobe/react-spectrum into dedupe-packages-and-fix-types
2 parents ab44f41 + 3b5fa6e commit bd15da6

File tree

18 files changed

+629
-45
lines changed

18 files changed

+629
-45
lines changed

packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ const argTypes = {
4545
max: 50000,
4646
step: 500
4747
},
48+
closeDelay: {
49+
control: 'number',
50+
min: 0,
51+
max: 50000,
52+
step: 500
53+
},
4854
offset: {
4955
control: 'number',
5056
min: -500,

packages/@react-spectrum/tree/stories/TreeView.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ const DynamicTreeItem = (props) => {
317317
};
318318

319319
export const TreeExampleDynamic: StoryObj<typeof TreeView> = {
320+
...TreeExampleStatic,
320321
render: (args: SpectrumTreeViewProps<unknown>) => (
321322
<div style={{width: '300px', resize: 'both', height: '90vh', overflow: 'auto'}}>
322323
<TreeView disabledKeys={['reports-1AB']} aria-label="test dynamic tree" items={rows} onExpandedChange={action('onExpandedChange')} onSelectionChange={action('onSelectionChange')} {...args}>
@@ -331,7 +332,6 @@ export const TreeExampleDynamic: StoryObj<typeof TreeView> = {
331332
</TreeView>
332333
</div>
333334
),
334-
...TreeExampleStatic,
335335
parameters: undefined
336336
};
337337

packages/@react-stately/layout/src/GridLayout.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,13 @@ export interface GridLayoutOptions {
5050
* The thickness of the drop indicator.
5151
* @default 2
5252
*/
53-
dropIndicatorThickness?: number
53+
dropIndicatorThickness?: number,
54+
/**
55+
* The fixed height of a loader element in px. This loader is specifically for
56+
* "load more" elements rendered when loading more rows at the root level or inside nested row/sections.
57+
* @default 48
58+
*/
59+
loaderHeight?: number
5460
}
5561

5662
const DEFAULT_OPTIONS = {
@@ -60,7 +66,8 @@ const DEFAULT_OPTIONS = {
6066
minSpace: new Size(18, 18),
6167
maxSpace: Infinity,
6268
maxColumns: Infinity,
63-
dropIndicatorThickness: 2
69+
dropIndicatorThickness: 2,
70+
loaderHeight: 48
6471
};
6572

6673
/**
@@ -84,7 +91,8 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
8491
|| (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
8592
|| (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
8693
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace))
87-
|| newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace;
94+
|| newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace
95+
|| newOptions.loaderHeight !== oldOptions.loaderHeight;
8896
}
8997

9098
update(invalidationContext: InvalidationContext<O>): void {
@@ -95,7 +103,8 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
95103
minSpace = DEFAULT_OPTIONS.minSpace,
96104
maxHorizontalSpace = DEFAULT_OPTIONS.maxSpace,
97105
maxColumns = DEFAULT_OPTIONS.maxColumns,
98-
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
106+
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness,
107+
loaderHeight = DEFAULT_OPTIONS.loaderHeight
99108
} = invalidationContext.layoutOptions || {};
100109
this.dropIndicatorThickness = dropIndicatorThickness;
101110

@@ -209,9 +218,16 @@ export class GridLayout<T, O extends GridLayoutOptions = GridLayoutOptions> exte
209218
// Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
210219
let lastNode = collection.getItem(collection.getLastKey()!);
211220
if (lastNode?.type === 'loader') {
212-
let rect = new Rect(horizontalSpacing, y, itemWidth, 0);
221+
if (skeletonCount > 0 || !lastNode.props.isLoading) {
222+
loaderHeight = 0;
223+
}
224+
const loaderWidth = visibleWidth - horizontalSpacing * 2;
225+
// Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
226+
// room for the loader alongside rendering the emptyState
227+
let rect = new Rect(horizontalSpacing, y, loaderWidth, loaderHeight);
213228
let layoutInfo = new LayoutInfo('loader', lastNode.key, rect);
214229
newLayoutInfos.set(lastNode.key, layoutInfo);
230+
y = layoutInfo.rect.maxY;
215231
}
216232

217233
this.layoutInfos = newLayoutInfos;

packages/@react-stately/layout/src/WaterfallLayout.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ export interface WaterfallLayoutOptions {
4343
* The thickness of the drop indicator.
4444
* @default 2
4545
*/
46-
dropIndicatorThickness?: number
46+
dropIndicatorThickness?: number,
47+
/**
48+
* The fixed height of a loader element in px. This loader is specifically for
49+
* "load more" elements rendered when loading more rows at the root level or inside nested row/sections.
50+
* @default 48
51+
*/
52+
loaderHeight?: number
4753
}
4854

4955
class WaterfallLayoutInfo extends LayoutInfo {
@@ -64,7 +70,8 @@ const DEFAULT_OPTIONS = {
6470
minSpace: new Size(18, 18),
6571
maxSpace: Infinity,
6672
maxColumns: Infinity,
67-
dropIndicatorThickness: 2
73+
dropIndicatorThickness: 2,
74+
loaderHeight: 48
6875
};
6976

7077
export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions = WaterfallLayoutOptions> extends Layout<Node<T>, O> implements LayoutDelegate, DropTargetDelegate {
@@ -80,7 +87,8 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
8087
|| (!(newOptions.minItemSize || DEFAULT_OPTIONS.minItemSize).equals(oldOptions.minItemSize || DEFAULT_OPTIONS.minItemSize))
8188
|| (!(newOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize).equals(oldOptions.maxItemSize || DEFAULT_OPTIONS.maxItemSize))
8289
|| (!(newOptions.minSpace || DEFAULT_OPTIONS.minSpace).equals(oldOptions.minSpace || DEFAULT_OPTIONS.minSpace))
83-
|| (newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace);
90+
|| (newOptions.maxHorizontalSpace !== oldOptions.maxHorizontalSpace)
91+
|| newOptions.loaderHeight !== oldOptions.loaderHeight;
8492
}
8593

8694
update(invalidationContext: InvalidationContext<O>): void {
@@ -90,7 +98,8 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
9098
minSpace = DEFAULT_OPTIONS.minSpace,
9199
maxHorizontalSpace = DEFAULT_OPTIONS.maxSpace,
92100
maxColumns = DEFAULT_OPTIONS.maxColumns,
93-
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness
101+
dropIndicatorThickness = DEFAULT_OPTIONS.dropIndicatorThickness,
102+
loaderHeight = DEFAULT_OPTIONS.loaderHeight
94103
} = invalidationContext.layoutOptions || {};
95104
this.dropIndicatorThickness = dropIndicatorThickness;
96105
let visibleWidth = this.virtualizer!.visibleRect.width;
@@ -174,19 +183,27 @@ export class WaterfallLayout<T extends object, O extends WaterfallLayoutOptions
174183
}
175184
}
176185

186+
// Reset all columns to the maximum for the next section. If loading, set to 0 so virtualizer doesn't render its body since there aren't items to render,
187+
// except if we are performing skeleton loading
188+
let isEmptyOrLoading = collection?.size === 0 && collection.getItem(collection.getFirstKey()!)?.type !== 'skeleton';
189+
let maxHeight = isEmptyOrLoading ? 0 : Math.max(...columnHeights);
190+
177191
// Always add the loader sentinel if present in the collection so we can make sure it is never virtualized out.
178192
// Add it under the first column for simplicity
179193
let lastNode = collection.getItem(collection.getLastKey()!);
180194
if (lastNode?.type === 'loader') {
181-
let rect = new Rect(horizontalSpacing, columnHeights[0], itemWidth, 0);
195+
if (skeletonCount > 0 || !lastNode.props.isLoading) {
196+
loaderHeight = 0;
197+
}
198+
const loaderWidth = visibleWidth - horizontalSpacing * 2;
199+
// Note that if the user provides isLoading to their sentinel during a case where they only want to render the emptyState, this will reserve
200+
// room for the loader alongside rendering the emptyState
201+
let rect = new Rect(horizontalSpacing, maxHeight, loaderWidth, loaderHeight);
182202
let layoutInfo = new LayoutInfo('loader', lastNode.key, rect);
183203
newLayoutInfos.set(lastNode.key, layoutInfo);
204+
maxHeight = layoutInfo.rect.maxY;
184205
}
185206

186-
// Reset all columns to the maximum for the next section. If loading, set to 0 so virtualizer doesn't render its body since there aren't items to render,
187-
// except if we are performing skeleton loading
188-
let isEmptyOrLoading = collection?.size === 0 && collection.getItem(collection.getFirstKey()!)?.type !== 'skeleton';
189-
let maxHeight = isEmptyOrLoading ? 0 : Math.max(...columnHeights);
190207
this.contentSize = new Size(this.virtualizer!.visibleRect.width, maxHeight);
191208
this.layoutInfos = newLayoutInfos;
192209
this.numColumns = numColumns;

packages/@react-stately/tooltip/src/useTooltipTriggerState.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,11 @@ export function useTooltipTriggerState(props: TooltipTriggerProps = {}): Tooltip
113113
let warmupTooltip = () => {
114114
closeOpenTooltips();
115115
ensureTooltipEntry();
116-
if (!isOpen && !globalWarmUpTimeout && !globalWarmedUp) {
116+
if (!isOpen && !globalWarmedUp) {
117+
if (globalWarmUpTimeout) {
118+
clearTimeout(globalWarmUpTimeout);
119+
}
120+
117121
globalWarmUpTimeout = setTimeout(() => {
118122
globalWarmUpTimeout = null;
119123
globalWarmedUp = true;

packages/@react-stately/tooltip/test/useTooltipTriggerState.test.js

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function ManualTooltipTrigger(props) {
5353
props.onOpenChange(isOpen);
5454
setOpen(isOpen);
5555
};
56-
56+
5757
return (
5858
<TooltipTrigger
5959
label={props.label}
@@ -77,6 +77,8 @@ describe('useTooltipTriggerState', function () {
7777

7878
afterEach(() => {
7979
onOpenChange.mockClear();
80+
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
81+
fireEvent.keyUp(document.activeElement, {key: 'Escape'});
8082
// there's global state, so we need to make sure to run out the cooldown for every test
8183
act(() => {jest.runAllTimers();});
8284
});
@@ -214,6 +216,125 @@ describe('useTooltipTriggerState', function () {
214216
});
215217
});
216218

219+
describe('warmup delay', () => {
220+
it('clears previous warmup timeout when open is called multiple times rapidly', () => {
221+
let delay = 1000;
222+
223+
function ManualTriggerComponent(props) {
224+
let state = useTooltipTriggerState(props);
225+
let ref = React.useRef();
226+
227+
let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, ref);
228+
229+
return (
230+
<span>
231+
<button
232+
ref={ref}
233+
{...triggerProps}
234+
data-testid="trigger-button">
235+
{props.children}
236+
</button>
237+
<button
238+
data-testid="manual-open"
239+
onClick={() => state.open(false)}>
240+
Manual Open
241+
</button>
242+
<button
243+
data-testid="manual-close"
244+
onClick={() => state.close(true)}>
245+
Manual Close
246+
</button>
247+
{state.isOpen &&
248+
<span role="tooltip" {...tooltipProps}>{props.tooltip}</span>}
249+
</span>
250+
);
251+
}
252+
253+
let {queryByRole, getByTestId} = render(
254+
<ManualTriggerComponent onOpenChange={onOpenChange} delay={delay} tooltip="Helpful information">
255+
Trigger
256+
</ManualTriggerComponent>
257+
);
258+
259+
fireEvent.mouseDown(document.body);
260+
fireEvent.mouseUp(document.body);
261+
262+
let manualOpenButton = getByTestId('manual-open');
263+
264+
// First call to open() - starts a warmup timer
265+
fireEvent.click(manualOpenButton);
266+
expect(queryByRole('tooltip')).toBeNull();
267+
268+
// Run 60% through the delay
269+
act(() => jest.advanceTimersByTime(delay * 0.6));
270+
expect(queryByRole('tooltip')).toBeNull();
271+
272+
// Second call to open() - should clear previous timeout and start a new one
273+
fireEvent.click(manualOpenButton);
274+
275+
// If the old timeout wasn't cleared, the tooltip would open after just 400ms more
276+
// But since it was cleared and restarted, we need the full 1000ms from the second call
277+
act(() => jest.advanceTimersByTime(delay * 0.4));
278+
expect(queryByRole('tooltip')).toBeNull();
279+
expect(onOpenChange).not.toHaveBeenCalled();
280+
281+
// Advancing the remaining 600ms from the second trigger should open it
282+
act(() => jest.advanceTimersByTime(delay * 0.6));
283+
expect(onOpenChange).toHaveBeenCalledWith(true);
284+
expect(queryByRole('tooltip')).toBeVisible();
285+
});
286+
287+
it('does not open immediately when open() is called twice during warmup', () => {
288+
function TooltipTriggerWithDoubleOpen(props) {
289+
let state = useTooltipTriggerState(props);
290+
let ref = React.useRef();
291+
292+
let {triggerProps, tooltipProps} = useTooltipTrigger(props, state, ref);
293+
294+
let onMouseEnter = (e) => {
295+
triggerProps.onMouseEnter?.(e);
296+
state.open(false);
297+
};
298+
299+
return (
300+
<span>
301+
<button ref={ref} {...triggerProps} onMouseEnter={onMouseEnter}>{props.children}</button>
302+
{state.isOpen &&
303+
<span role="tooltip" {...tooltipProps}>{props.tooltip}</span>}
304+
</span>
305+
);
306+
}
307+
308+
let delay = 1000;
309+
310+
let {queryByRole, getByRole} = render(
311+
<TooltipTriggerWithDoubleOpen onOpenChange={onOpenChange} delay={delay} tooltip="Helpful information">
312+
Trigger
313+
</TooltipTriggerWithDoubleOpen>
314+
);
315+
316+
fireEvent.mouseDown(document.body);
317+
fireEvent.mouseUp(document.body);
318+
319+
let button = getByRole('button');
320+
321+
fireEvent.mouseEnter(button);
322+
fireEvent.mouseMove(button);
323+
324+
expect(onOpenChange).not.toHaveBeenCalled();
325+
expect(queryByRole('tooltip')).toBeNull();
326+
327+
// run halfway through the delay timer and confirm that it is still closed
328+
act(() => jest.advanceTimersByTime(delay / 2));
329+
expect(queryByRole('tooltip')).toBeNull();
330+
331+
// run through the rest of the delay timer and confirm that it has opened
332+
act(() => jest.advanceTimersByTime(delay / 2));
333+
expect(onOpenChange).toHaveBeenCalledWith(true);
334+
expect(getByRole('tooltip')).toBeVisible();
335+
});
336+
});
337+
217338
describe('multiple controlled tooltips', () => {
218339
it('closes previus tooltip when opening a new one', () => {
219340
let secondOnOpenChange = jest.fn();

packages/dev/s2-docs/pages/s2/Picker.mdx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,15 +249,16 @@ Use the `renderValue` prop to provide a custom element to display selected items
249249

250250
```tsx render
251251
"use client";
252-
import {Avatar, Picker, PickerItem, Text} from '@react-spectrum/s2';
253-
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
252+
import {Avatar, AvatarGroup, Picker, PickerItem, Text} from '@react-spectrum/s2';
254253

254+
///- begin collapse -///
255255
let users = [
256256
{id: 'abraham-baker', avatar: 'https://www.untitledui.com/images/avatars/abraham-baker', name: 'Abraham Baker', email: 'abraham@example.com'},
257257
{id: 'adriana-sullivan', avatar: 'https://www.untitledui.com/images/avatars/adriana-sullivan', name: 'Adriana Sullivan', email: 'adriana@example.com'},
258258
{id: 'jonathan-kelly', avatar: 'https://www.untitledui.com/images/avatars/jonathan-kelly', name: 'Jonathan Kelly', email: 'jonathan@example.com'},
259259
{id: 'zara-bush', avatar: 'https://www.untitledui.com/images/avatars/zara-bush', name: 'Zara Bush', email: 'zara@example.com'}
260260
];
261+
///- end collapse -///
261262

262263
function Example() {
263264
return (
@@ -268,11 +269,11 @@ function Example() {
268269
selectionMode={"multiple"}
269270
///- begin highlight -///
270271
renderValue={(selectedItems) => (
271-
<div className={style({ display: 'flex', gap: 4, height: '80%' })}>
272+
<AvatarGroup aria-label="Selected users">
272273
{selectedItems.map(item => (
273-
<Avatar slot={null} key={item.id} src={item.avatar} alt={item.name} />
274+
<Avatar key={item.id} src={item.avatar} alt={item.name} />
274275
))}
275-
</div>
276+
</AvatarGroup>
276277
)}
277278
///- end highlight -///
278279
>

0 commit comments

Comments
 (0)