Skip to content

Commit 12f5579

Browse files
shakyShaneShane Osbourne
andauthored
ntp: virtual list for favorites (#1269)
* ntp: virtual list for favorites * fix pulse animation * fixed test * some comments * linting * remove build changes * types/naming * optimize expand/collapse --------- Co-authored-by: Shane Osbourne <[email protected]>
1 parent bcf726e commit 12f5579

File tree

9 files changed

+354
-163
lines changed

9 files changed

+354
-163
lines changed
Lines changed: 240 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
1-
import { h } from 'preact';
2-
import { useId, useMemo } from 'preact/hooks';
1+
import { Fragment, h } from 'preact';
2+
import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
33
import { memo } from 'preact/compat';
44
import cn from 'classnames';
55

66
import styles from './Favorites.module.css';
7-
import { Placeholder, PlusIconMemo, TileMemo } from './Tile.js';
87
import { ShowHideButton } from '../../components/ShowHideButton.jsx';
98
import { useTypedTranslationWith } from '../../types.js';
109
import { usePlatformName } from '../../settings.provider.js';
1110
import { useDropzoneSafeArea } from '../../dropzone.js';
11+
import { TileRow } from './TileRow.js';
12+
import { FavoritesContext } from './FavoritesProvider.js';
1213

1314
/**
1415
* @typedef {import('../../../../../types/new-tab.js').Expansion} Expansion
1516
* @typedef {import('../../../../../types/new-tab.js').Favorite} Favorite
1617
* @typedef {import('../../../../../types/new-tab.js').FavoritesOpenAction['target']} OpenTarget
1718
*/
1819
export const FavoritesMemo = memo(Favorites);
20+
export const ROW_CAPACITY = 6;
21+
/**
22+
* Note: These values MUST match exactly what's defined in the CSS.
23+
*/
24+
const ITEM_HEIGHT = 98;
25+
const ROW_GAP = 8;
1926

2027
/**
2128
* Favorites Grid.
@@ -30,65 +37,260 @@ export const FavoritesMemo = memo(Favorites);
3037
* @param {() => void} props.add
3138
*/
3239
export function Favorites({ gridRef, favorites, expansion, toggle, openContextMenu, openFavorite, add }) {
33-
const platformName = usePlatformName();
3440
const { t } = useTypedTranslationWith(/** @type {import('../strings.json')} */ ({}));
35-
const safeArea = useDropzoneSafeArea();
36-
37-
const ROW_CAPACITY = 6;
3841

3942
// see: https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/
4043
const WIDGET_ID = useId();
4144
const TOGGLE_ID = useId();
4245

43-
const ITEM_PREFIX = useId();
44-
const placeholders = calculatePlaceholders(favorites.length, ROW_CAPACITY);
4546
const hiddenCount = expansion === 'collapsed' ? favorites.length - ROW_CAPACITY : 0;
47+
const rowHeight = ITEM_HEIGHT + ROW_GAP;
48+
const canToggleExpansion = favorites.length >= ROW_CAPACITY;
4649

47-
// only recompute the list
48-
const items = useMemo(() => {
49-
return favorites
50-
.map((item, index) => {
51-
return (
52-
<TileMemo
53-
url={item.url}
54-
faviconSrc={item.favicon?.src}
55-
faviconMax={item.favicon?.maxAvailableSize}
56-
title={item.title}
57-
key={item.id + item.favicon?.src + item.favicon?.maxAvailableSize}
58-
id={item.id}
59-
index={index}
50+
return (
51+
<div class={cn(styles.root, !canToggleExpansion && styles.bottomSpace)} data-testid="FavoritesConfigured">
52+
<VirtualizedGridRows
53+
WIDGET_ID={WIDGET_ID}
54+
favorites={favorites}
55+
rowHeight={rowHeight}
56+
add={add}
57+
expansion={expansion}
58+
openFavorite={openFavorite}
59+
openContextMenu={openContextMenu}
60+
/>
61+
{canToggleExpansion && (
62+
<div
63+
className={cn({
64+
[styles.showhide]: true,
65+
[styles.showhideVisible]: canToggleExpansion,
66+
})}
67+
>
68+
<ShowHideButton
69+
buttonAttrs={{
70+
'aria-expanded': expansion === 'expanded',
71+
'aria-pressed': expansion === 'expanded',
72+
'aria-controls': WIDGET_ID,
73+
id: TOGGLE_ID,
74+
}}
75+
text={
76+
expansion === 'expanded' ? t('favorites_show_less') : t('favorites_show_more', { count: String(hiddenCount) })
77+
}
78+
onClick={toggle}
6079
/>
61-
);
62-
})
63-
.concat(
64-
Array.from({ length: placeholders }).map((_, index) => {
65-
if (index === 0) {
66-
return <PlusIconMemo key="placeholder-plus" onClick={add} />;
67-
}
68-
return <Placeholder key={`placeholder-${index}`} />;
69-
}),
70-
);
71-
}, [favorites, placeholders, ITEM_PREFIX, add]);
80+
</div>
81+
)}
82+
</div>
83+
);
84+
}
85+
86+
/**
87+
* Favorites Grid. This will take a list of Favorites, split it into chunks (rows)
88+
* and then layout the rows based on an offset from the top of the container.
89+
*
90+
* Doing this means we can just render the items in the current viewport
91+
*
92+
* @param {object} props
93+
* @param {string} props.WIDGET_ID
94+
* @param {number} props.rowHeight
95+
* @param {Expansion} props.expansion
96+
* @param {Favorite[]} props.favorites
97+
* @param {(id: string) => void} props.openContextMenu
98+
* @param {(id: string, url: string, target: OpenTarget) => void} props.openFavorite
99+
* @param {() => void} props.add
100+
*/
101+
function VirtualizedGridRows({ WIDGET_ID, rowHeight, favorites, expansion, openFavorite, openContextMenu, add }) {
102+
const platformName = usePlatformName();
103+
104+
// convert the list of favorites into chunks of length ROW_CAPACITY
105+
const rows = useMemo(() => {
106+
const chunked = [];
107+
let inner = [];
108+
for (let i = 0; i < favorites.length; i++) {
109+
inner.push(favorites[i]);
110+
if (inner.length === ROW_CAPACITY) {
111+
chunked.push(inner.slice());
112+
inner = [];
113+
}
114+
if (i === favorites.length - 1) {
115+
chunked.push(inner.slice());
116+
inner = [];
117+
}
118+
}
119+
return chunked;
120+
}, [favorites]);
121+
122+
// get a ref for the favorites' grid, this will allow it to receive drop events,
123+
// and the ref can also be used for reading the offset (eg: if other elements are above it)
124+
const safeAreaRef = /** @type {import("preact").RefObject<HTMLDivElement>} */ (useDropzoneSafeArea());
125+
const containerHeight = expansion === 'collapsed' ? rowHeight : rows.length * rowHeight;
126+
127+
return (
128+
<div
129+
className={styles.grid}
130+
style={{ height: containerHeight + 'px' }}
131+
id={WIDGET_ID}
132+
ref={safeAreaRef}
133+
onContextMenu={getContextMenuHandler(openContextMenu)}
134+
onClick={getOnClickHandler(openFavorite, platformName)}
135+
>
136+
{rows.length === 0 && <TileRow key={'empty-rows'} items={[]} topOffset={0} add={add} />}
137+
{rows.length > 0 && <Inner rows={rows} safeAreaRef={safeAreaRef} rowHeight={rowHeight} add={add} />}
138+
</div>
139+
);
140+
}
141+
142+
/**
143+
* This is a potentially expensive operation. Especially when going from 'collapsed' to expanded. So, we force
144+
* the tiles to render after the main thread is cleared by NOT using the 'expansion' from the parent, but instead
145+
* subscribing to the same update asynchronously. If we accepted the 'expansion' prop in this component and used
146+
* it directly, it would cause the browser to lock up (on slow devices) when expanding from 1 row to a full screen.
147+
*
148+
* @param {object} props
149+
* @param {Favorite[][]} props.rows
150+
* @param {import("preact").RefObject<HTMLDivElement>} props.safeAreaRef
151+
* @param {number} props.rowHeight
152+
* @param {()=>void} props.add
153+
*/
154+
function Inner({ rows, safeAreaRef, rowHeight, add }) {
155+
const { onConfigChanged, state } = useContext(FavoritesContext);
156+
const [expansion, setExpansion] = useState(state.config?.expansion || 'collapsed');
157+
158+
// force the children to be rendered after the main thread is cleared
159+
useEffect(() => {
160+
return onConfigChanged((config) => {
161+
// when expanding, wait for the main thread to be clear
162+
if (config.expansion === 'expanded') {
163+
setTimeout(() => {
164+
setExpansion(config.expansion);
165+
}, 0);
166+
} else {
167+
setExpansion(config.expansion);
168+
}
169+
});
170+
}, [onConfigChanged]);
171+
172+
// set the start/end indexes of the elements
173+
const [{ start, end }, setVisibleRange] = useState({ start: 0, end: 1 });
72174

175+
// hold a mutable value that we update on resize
176+
const gridOffset = useRef(0);
177+
178+
// When called, make the expensive call to `getBoundingClientRect` to determine the offset of
179+
// the grid wrapper.
180+
function updateGlobals() {
181+
if (!safeAreaRef.current) return;
182+
const rec = safeAreaRef.current.getBoundingClientRect();
183+
gridOffset.current = rec.y + window.scrollY;
184+
}
185+
186+
// decide which the start/end indexes should be, based on scroll position.
187+
// NOTE: this is called on scroll, so must not incur expensive checks/measurements - math only!
188+
function setVisibleRows() {
189+
if (!safeAreaRef.current) return console.warn('cannot access ref');
190+
if (!gridOffset.current) return console.warn('cannot access ref');
191+
const offset = gridOffset.current;
192+
const end = window.scrollY + window.innerHeight - offset;
193+
let start;
194+
if (offset > window.scrollY) {
195+
start = 0;
196+
} else {
197+
start = window.scrollY - offset;
198+
}
199+
const startIndex = Math.floor(start / rowHeight);
200+
const endIndex = Math.min(Math.ceil(end / rowHeight), rows.length);
201+
setVisibleRange({ start: startIndex, end: endIndex });
202+
}
203+
204+
useLayoutEffect(() => {
205+
// always update globals first
206+
updateGlobals();
207+
208+
// and set visible rows once the size is known
209+
setVisibleRows();
210+
211+
const controller = new AbortController();
212+
window.addEventListener(
213+
'resize',
214+
() => {
215+
updateGlobals();
216+
setVisibleRows();
217+
},
218+
{ signal: controller.signal },
219+
);
220+
221+
window.addEventListener(
222+
'scroll',
223+
() => {
224+
setVisibleRows();
225+
},
226+
{ signal: controller.signal },
227+
);
228+
229+
return () => {
230+
controller.abort();
231+
};
232+
}, [rows.length]);
233+
234+
// now, we decide which items to show based on the widget expansion
235+
// prettier-ignore
236+
const subsetOfRowsToRender = expansion === 'collapsed'
237+
// if it's 'collapsed', just 1 row to show (the first one)
238+
? [rows[0]]
239+
// otherwise, select the window between start/end
240+
// the '+ 1' is an additional row to render offscreen - which helps with keyboard navigation.
241+
: rows.slice(start, end + 1);
242+
243+
// read a global property on <html> to determine if an element was recently dropped.
244+
// this is used for animation (the pulse) - it's easier this way because the act of dropping
245+
// a tile can cause it to render inside a different row, meaning the `key` is invalidated and so the dom-node is recreated
246+
const dropped = document.documentElement.dataset.dropped;
247+
248+
return (
249+
<Fragment>
250+
{subsetOfRowsToRender.map((items, rowIndex) => {
251+
const topOffset = (start + rowIndex) * rowHeight;
252+
const keyed = `-${start + rowIndex}-`;
253+
return <TileRow key={keyed} dropped={dropped} items={items} topOffset={topOffset} add={add} />;
254+
})}
255+
</Fragment>
256+
);
257+
}
258+
259+
/**
260+
* Handle right-clicks
261+
*
262+
* @param {(id: string) => void} openContextMenu
263+
*/
264+
function getContextMenuHandler(openContextMenu) {
73265
/**
74266
* @param {MouseEvent} event
75267
*/
76-
function onContextMenu(event) {
268+
return (event) => {
77269
let target = /** @type {HTMLElement|null} */ (event.target);
78270
while (target && target !== event.currentTarget) {
79-
if (typeof target.dataset.id === 'string') {
271+
if (typeof target.dataset.id === 'string' && 'href' in target && typeof target.href === 'string') {
80272
event.preventDefault();
81273
event.stopImmediatePropagation();
82274
return openContextMenu(target.dataset.id);
83275
} else {
84276
target = target.parentElement;
85277
}
86278
}
87-
}
279+
};
280+
}
281+
282+
/**
283+
* Following a click on a favorite, walk up the DOM from the clicked
284+
* element to find the <a>. This is done to prevent needing a click handler
285+
* on every element.
286+
* @param {(id: string, url: string, target: OpenTarget) => void} openFavorite
287+
* @param {ImportMeta['platform']} platformName
288+
*/
289+
function getOnClickHandler(openFavorite, platformName) {
88290
/**
89291
* @param {MouseEvent} event
90292
*/
91-
function onClick(event) {
293+
return (event) => {
92294
let target = /** @type {HTMLElement|null} */ (event.target);
93295
while (target && target !== event.currentTarget) {
94296
if (typeof target.dataset.id === 'string' && 'href' in target && typeof target.href === 'string') {
@@ -105,53 +307,5 @@ export function Favorites({ gridRef, favorites, expansion, toggle, openContextMe
105307
target = target.parentElement;
106308
}
107309
}
108-
}
109-
110-
const canToggleExpansion = items.length > ROW_CAPACITY;
111-
112-
return (
113-
<div class={cn(styles.root, !canToggleExpansion && styles.bottomSpace)} data-testid="FavoritesConfigured">
114-
<div class={styles.grid} id={WIDGET_ID} ref={safeArea} onContextMenu={onContextMenu} onClick={onClick}>
115-
{items.slice(0, expansion === 'expanded' ? undefined : ROW_CAPACITY)}
116-
</div>
117-
{canToggleExpansion && (
118-
<div
119-
className={cn({
120-
[styles.showhide]: true,
121-
[styles.showhideVisible]: canToggleExpansion,
122-
})}
123-
>
124-
<ShowHideButton
125-
buttonAttrs={{
126-
'aria-expanded': expansion === 'expanded',
127-
'aria-pressed': expansion === 'expanded',
128-
'aria-controls': WIDGET_ID,
129-
id: TOGGLE_ID,
130-
}}
131-
text={
132-
expansion === 'expanded' ? t('favorites_show_less') : t('favorites_show_more', { count: String(hiddenCount) })
133-
}
134-
onClick={toggle}
135-
/>
136-
</div>
137-
)}
138-
</div>
139-
);
140-
}
141-
142-
/**
143-
* @param {number} totalItems
144-
* @param {number} itemsPerRow
145-
* @return {number|number}
146-
*/
147-
function calculatePlaceholders(totalItems, itemsPerRow) {
148-
if (totalItems === 0) return itemsPerRow;
149-
if (totalItems === itemsPerRow) return 1;
150-
// Calculate how many items are left over in the last row
151-
const itemsInLastRow = totalItems % itemsPerRow;
152-
153-
// If there are leftover items, calculate the placeholders needed to fill the last row
154-
const placeholders = itemsInLastRow > 0 ? itemsPerRow - itemsInLastRow : 1;
155-
156-
return placeholders;
310+
};
157311
}

special-pages/pages/new-tab/app/favorites/components/Favorites.module.css

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,14 @@
3434
.grid {
3535
grid-area: grid;
3636
display: grid;
37-
grid-template-columns: repeat(6, 1fr);
38-
align-items: start;
39-
grid-row-gap: 0.5rem;
37+
position: relative;
4038
margin-left: -0.75rem;
4139
margin-right: -0.75rem;
4240
}
4341

42+
.gridRow {
43+
display: grid;
44+
grid-template-columns: repeat(6, 1fr);
45+
align-items: start;
46+
position: absolute;
47+
}

0 commit comments

Comments
 (0)