Skip to content
Merged
Show file tree
Hide file tree
Changes from 60 commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5985d3d
feat(feed): widget rail above the feed, behind feed_widgets
rebelchris Oct 19, 2026
bdb2cd3
feat(feed): default rail is the top story, the ad and the add tile
rebelchris Oct 19, 2026
88223e5
feat(feed): drag, resize and remove widgets the home-screen way
rebelchris Oct 19, 2026
1b85a08
fix(feed): stop the widget rail fighting sideways scrolling
rebelchris Oct 19, 2026
21a19a9
feat(feed): steady resize preview and a single top story
rebelchris Oct 20, 2026
c504983
fix(feed): let widgets shrink to Small
rebelchris Oct 20, 2026
0959d1e
feat(feed): widget rail foundation (data, layout, registry)
rebelchris Oct 20, 2026
372b5f3
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
ea69874
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
4d44279
fix(feed): write the stored widget list back, render a filtered copy
rebelchris Oct 20, 2026
d24da62
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
889a58e
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
616f58d
fix(feed): rail ad only where it is seen, swipe in edit mode, review …
rebelchris Oct 20, 2026
d6749f3
docs(feed): widget kinds are the client's now
rebelchris Oct 20, 2026
bd3f131
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
2a6c432
refactor(feed): move drag, resize and keyboard sorting to their own PR
rebelchris Oct 20, 2026
39570a5
feat(feed): drag, resize and keyboard sorting for the widget rail
rebelchris Oct 20, 2026
f74586e
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
6661e68
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
47f9853
feat(feed): check each widget's config on the client before it is used
rebelchris Oct 20, 2026
a7df581
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
af712cf
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 20, 2026
234a33c
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
dccb435
feat(feed): wrapped rail, default from GrowthBook, open sizes, lazy w…
rebelchris Oct 20, 2026
4e999cc
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
f1c8311
fix(feed): only an edit from the default records startedFrom
rebelchris Oct 20, 2026
80d8645
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
34347fc
refactor(feed): read the stored rail from a module free of the widgets
rebelchris Oct 20, 2026
05df9c3
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
f04be99
refactor(feed): keep the ad column constant with the types
rebelchris Oct 20, 2026
d2b23ae
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 20, 2026
c41e329
feat(feed): rail never moves the grid, loads lazily, fetches on screen
rebelchris Oct 20, 2026
8422d9f
Merge remote-tracking branch 'origin/feat/feed-widget-rail' into feat…
rebelchris Oct 20, 2026
856ca9e
feat(feed): resize reaches the kind's named sizes, not a fixed three
rebelchris Oct 20, 2026
49cdff9
Merge branch 'main' into feat/feed-widgets-foundation
rebelchris Oct 20, 2026
002cfbb
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris Oct 21, 2026
5b52b14
Merge remote-tracking branch 'origin/main' into feat/feed-widgets-fou…
rebelchris Oct 21, 2026
b0e7dc6
Merge remote-tracking branch 'origin/feat/feed-widgets-foundation' in…
rebelchris Oct 21, 2026
1c9f615
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
653d069
feat(feed): a newer rail is read-only, a newer widget config is left …
rebelchris Oct 21, 2026
d14a920
feat(feed-widgets): cache the hero payload and hold the lead per session
rebelchris Oct 21, 2026
9a6fb43
Merge branch 'feat/feed-widgets-foundation' into feat/feed-widget-rail
rebelchris Oct 21, 2026
b0b3c11
feat(feed-widgets): shared hero lead, no-fill hand-back, steady first…
rebelchris Oct 21, 2026
601120a
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
73c44a1
fix(feed-widgets): an empty ad's cell adds a widget at the end
rebelchris Oct 21, 2026
f4caba3
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
629f8ed
fix(feed-widgets): the rail's ad is the feed's own ad card
rebelchris Oct 21, 2026
4d0f146
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
8f6225a
Merge remote-tracking branch 'origin/main' into feat/feed-widget-rail
rebelchris Oct 21, 2026
ec4f2ad
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
0449d97
Merge branch 'main' into feat/feed-widget-rail
rebelchris Oct 21, 2026
bc63425
fix(feed-widgets): a newer rail offers no edits, writes report back
rebelchris Oct 21, 2026
f4f6ab2
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
cc325e5
fix(feed-widgets): drag edits log once saved, no hold-to-edit on a ne…
rebelchris Oct 21, 2026
1216ea2
Merge branch 'main' into feat/feed-widget-rail
rebelchris Oct 21, 2026
98180f0
fix(feed-widgets): the rail is as tall as the feed's cards
rebelchris Oct 21, 2026
5e2fbdf
Merge branch 'feat/feed-widget-rail' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
0a832d0
Merge remote-tracking branch 'origin/main' into feat/feed-widget-rail…
rebelchris Oct 21, 2026
1888bf3
chore(feed-widgets): drop the menu-only edit hook useRailEditing repl…
rebelchris Oct 21, 2026
c4b9459
fix(feed-widgets): touch fixes for hold-to-edit and phone resizing
rebelchris Oct 21, 2026
cc073e7
chore(feed-widgets): keep sizeForScreen's doc on sizeForScreen
rebelchris Oct 21, 2026
d111807
Merge branch 'main' into feat/feed-widget-rail-editing
rebelchris Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/shared/src/features/feedWidgets/FeedWidgetMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,21 @@ export type FeedWidgetMenuAction =
*/
export const FeedWidgetMenu = ({
widget,
sizes = feedWidgetSizes(widget.kind),
canMoveLeft,
canMoveRight,
onAction,
}: {
widget: FeedWidget;
/** The sizes this screen can show, the kind's own by default. */
sizes?: FeedWidgetSize[];
canMoveLeft: boolean;
canMoveRight: boolean;
onAction: (action: FeedWidgetMenuAction) => void;
}): ReactElement => {
const { name } = getFeedWidgetDefinition(widget.kind);
const options: MenuItemProps[] = [
...feedWidgetSizes(widget.kind).map((size) => ({
...sizes.map((size) => ({
label: feedWidgetSizeLabel[size],
icon: size === widget.size ? <VIcon /> : undefined,
disabled: size === widget.size,
Expand Down
90 changes: 90 additions & 0 deletions packages/shared/src/features/feedWidgets/FeedWidgetRail.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils';
import { QueryClient } from '@tanstack/react-query';
Expand Down Expand Up @@ -287,6 +288,64 @@ describe('FeedWidgetRail', () => {
});
});

it('moves a widget with the keyboard and writes once, on drop', async () => {
mockHero();

renderRail({ feedWidgets: { version: 1, widgets: twoWidgets } });
await revealWidgets();

await waitFor(() =>
expect(screen.getByText('First headline')).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
const widget = screen.getByRole('button', {
name: 'Happening Now, Tall, position 2',
});
fireEvent.keyDown(widget, { key: ' ' });
fireEvent.keyDown(widget, { key: 'ArrowLeft' });
expect(setSettings).not.toHaveBeenCalled();

fireEvent.keyDown(
screen.getByRole('button', { name: 'Happening Now, Tall, position 1' }),
{ key: ' ' },
);
expect(setSettings).toHaveBeenCalledTimes(1);
expect(setSettings).toHaveBeenCalledWith({
flags: {
feedWidgets: {
version: 1,
widgets: [twoWidgets[1], twoWidgets[0]],
},
},
});
});

it('drops a keyboard move on Escape without writing', async () => {
mockHero();

renderRail({ feedWidgets: { version: 1, widgets: twoWidgets } });
await revealWidgets();

await waitFor(() =>
expect(screen.getByText('First headline')).toBeInTheDocument(),
);
fireEvent.click(screen.getByRole('button', { name: 'Customize' }));
const widget = screen.getByRole('button', {
name: 'Happening Now, Tall, position 2',
});
fireEvent.keyDown(widget, { key: ' ' });
fireEvent.keyDown(widget, { key: 'ArrowLeft' });
fireEvent.keyDown(window, { key: 'Escape' });

expect(setSettings).not.toHaveBeenCalled();
expect(widgetKinds()).toEqual([
FeedWidgetKind.TopStory,
FeedWidgetKind.HappeningNow,
]);
// Still editing: the Esc canceled the move, not the session.
expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
});

it('places no slot when its column is off the first screen', async () => {
mockHero();

Expand Down Expand Up @@ -342,4 +401,35 @@ describe('FeedWidgetRail', () => {
screen.getByRole('button', { name: 'Customize' }),
).toBeInTheDocument();
});

it('lets a link through once a hold-to-edit session is over', async () => {
mockHero();

renderRail();
await revealWidgets();
await waitFor(() =>
expect(
screen.getByRole('heading', { name: 'First hero post' }),
).toBeInTheDocument(),
);
const [widget] = screen.getAllByTestId('feedWidget');

jest.useFakeTimers();
fireEvent.pointerDown(widget, { pointerType: 'touch', button: 0 });
act(() => {
jest.advanceTimersByTime(500);
});
jest.useRealTimers();
// A long press ends without a click, so nothing clears the hold there.
fireEvent.pointerUp(widget, { pointerType: 'touch' });
fireEvent.click(screen.getByRole('button', { name: 'Done' }));

const [link] = within(widget).getAllByRole('link');
// The widget's capture handler stops a swallowed click before it reaches
// the link itself.
const onLinkClick = jest.fn();
link.addEventListener('click', onLinkClick);
fireEvent.click(link);
expect(onLinkClick).toHaveBeenCalled();
});
});
Loading
Loading