Skip to content

Commit b6bd57e

Browse files
rebelchrisclaude
andauthored
feat(feed): drag, resize and keyboard sorting for the widget rail (#6969)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 07a8406 commit b6bd57e

7 files changed

Lines changed: 1338 additions & 128 deletions

File tree

‎packages/shared/src/features/feedWidgets/FeedWidgetMenu.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,21 @@ export type FeedWidgetMenuAction =
2727
*/
2828
export const FeedWidgetMenu = ({
2929
widget,
30+
sizes = feedWidgetSizes(widget.kind),
3031
canMoveLeft,
3132
canMoveRight,
3233
onAction,
3334
}: {
3435
widget: FeedWidget;
36+
/** The sizes this screen can show, the kind's own by default. */
37+
sizes?: FeedWidgetSize[];
3538
canMoveLeft: boolean;
3639
canMoveRight: boolean;
3740
onAction: (action: FeedWidgetMenuAction) => void;
3841
}): ReactElement => {
3942
const { name } = getFeedWidgetDefinition(widget.kind);
4043
const options: MenuItemProps[] = [
41-
...feedWidgetSizes(widget.kind).map((size) => ({
44+
...sizes.map((size) => ({
4245
label: feedWidgetSizeLabel[size],
4346
icon: size === widget.size ? <VIcon /> : undefined,
4447
disabled: size === widget.size,

‎packages/shared/src/features/feedWidgets/FeedWidgetRail.spec.tsx‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
render,
77
screen,
88
waitFor,
9+
within,
910
} from '@testing-library/react';
1011
import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils';
1112
import { QueryClient } from '@tanstack/react-query';
@@ -287,6 +288,64 @@ describe('FeedWidgetRail', () => {
287288
});
288289
});
289290

291+
it('moves a widget with the keyboard and writes once, on drop', async () => {
292+
mockHero();
293+
294+
renderRail({ feedWidgets: { version: 1, widgets: twoWidgets } });
295+
await revealWidgets();
296+
297+
await waitFor(() =>
298+
expect(screen.getByText('First headline')).toBeInTheDocument(),
299+
);
300+
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
301+
const widget = screen.getByRole('button', {
302+
name: 'Happening Now, Tall, position 2',
303+
});
304+
fireEvent.keyDown(widget, { key: ' ' });
305+
fireEvent.keyDown(widget, { key: 'ArrowLeft' });
306+
expect(setSettings).not.toHaveBeenCalled();
307+
308+
fireEvent.keyDown(
309+
screen.getByRole('button', { name: 'Happening Now, Tall, position 1' }),
310+
{ key: ' ' },
311+
);
312+
expect(setSettings).toHaveBeenCalledTimes(1);
313+
expect(setSettings).toHaveBeenCalledWith({
314+
flags: {
315+
feedWidgets: {
316+
version: 1,
317+
widgets: [twoWidgets[1], twoWidgets[0]],
318+
},
319+
},
320+
});
321+
});
322+
323+
it('drops a keyboard move on Escape without writing', async () => {
324+
mockHero();
325+
326+
renderRail({ feedWidgets: { version: 1, widgets: twoWidgets } });
327+
await revealWidgets();
328+
329+
await waitFor(() =>
330+
expect(screen.getByText('First headline')).toBeInTheDocument(),
331+
);
332+
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
333+
const widget = screen.getByRole('button', {
334+
name: 'Happening Now, Tall, position 2',
335+
});
336+
fireEvent.keyDown(widget, { key: ' ' });
337+
fireEvent.keyDown(widget, { key: 'ArrowLeft' });
338+
fireEvent.keyDown(window, { key: 'Escape' });
339+
340+
expect(setSettings).not.toHaveBeenCalled();
341+
expect(widgetKinds()).toEqual([
342+
FeedWidgetKind.TopStory,
343+
FeedWidgetKind.HappeningNow,
344+
]);
345+
// Still editing: the Esc canceled the move, not the session.
346+
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
347+
});
348+
290349
it('places no slot when its column is off the first screen', async () => {
291350
mockHero();
292351

@@ -342,4 +401,35 @@ describe('FeedWidgetRail', () => {
342401
screen.getByRole('button', { name: 'Customize' }),
343402
).toBeInTheDocument();
344403
});
404+
405+
it('lets a link through once a hold-to-edit session is over', async () => {
406+
mockHero();
407+
408+
renderRail();
409+
await revealWidgets();
410+
await waitFor(() =>
411+
expect(
412+
screen.getByRole('heading', { name: 'First hero post' }),
413+
).toBeInTheDocument(),
414+
);
415+
const [widget] = screen.getAllByTestId('feedWidget');
416+
417+
jest.useFakeTimers();
418+
fireEvent.pointerDown(widget, { pointerType: 'touch', button: 0 });
419+
act(() => {
420+
jest.advanceTimersByTime(500);
421+
});
422+
jest.useRealTimers();
423+
// A long press ends without a click, so nothing clears the hold there.
424+
fireEvent.pointerUp(widget, { pointerType: 'touch' });
425+
fireEvent.click(screen.getByRole('button', { name: 'Done' }));
426+
427+
const [link] = within(widget).getAllByRole('link');
428+
// The widget's capture handler stops a swallowed click before it reaches
429+
// the link itself.
430+
const onLinkClick = jest.fn();
431+
link.addEventListener('click', onLinkClick);
432+
fireEvent.click(link);
433+
expect(onLinkClick).toHaveBeenCalled();
434+
});
345435
});

0 commit comments

Comments
 (0)