/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs";
import {
isSponsored,
MIN_RICH_FAVICON_SIZE,
MIN_SMALL_FAVICON_SIZE,
SPOC_TYPE,
TOP_SITES_CONTEXT_MENU_OPTIONS,
TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS,
TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS,
TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS,
TOP_SITES_SOURCE,
} from "./TopSitesConstants";
import { PinnedAreaOverlay } from "./PinnedAreaOverlay.jsx";
import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
import { ImpressionStats } from "../DiscoveryStreamImpressionStats/ImpressionStats";
import React from "react";
import { ScreenshotUtils } from "content-src/lib/screenshot-utils";
import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs";
import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
import { TopSiteImpressionWrapper } from "./TopSiteImpressionWrapper";
import { connect } from "react-redux";
const NEWTAB_SOURCE = "newtab";
// Tilt so the lifted drag ghost reads as "picked up" (counter-clockwise).
const DRAG_GHOST_ROTATION_DEG = -7.5;
// Cursor relative to the ghost's top edge; negative = cursor sits just above it.
const DRAG_GHOST_CURSOR_INSET = -12;
/**
* Clones the dragged tile, styles it (size + rotation, pin/menu chrome hidden via
* `.drag-ghost`), and hands it to native setDragImage so the platform draws and
* follows it. Mounted off-screen under the tile's parent to keep the cascade.
* Caller MUST call destroy() on dragend.
*/
function createDragGhost(e, el) {
const restingSize = Math.round(el.getBoundingClientRect().width);
const mountPoint = el.parentElement || document.body;
const clone = el.cloneNode(true);
clone.classList.add("drag-ghost", "active");
// Drop any open context menu from the clone so only the tile is lifted.
clone.querySelector(".context-menu")?.remove();
// Size the ghost to the hover/expanded tile (--col-width, per Figma), falling
// back to the resting size where that token isn't defined.
clone.style.cssText = `position:fixed;top:-9999px;left:-9999px;width:var(--col-width, ${restingSize}px);height:var(--col-width, ${restingSize}px);margin:0;pointer-events:none;transform:rotate(${DRAG_GHOST_ROTATION_DEG}deg)`;
mountPoint.appendChild(clone);
// offsetWidth is the layout width, unaffected by the rotate transform.
e.dataTransfer.setDragImage(
clone,
clone.offsetWidth / 2,
DRAG_GHOST_CURSOR_INSET
);
return {
destroy() {
clone.remove();
},
};
}
export class TopSiteLink extends React.PureComponent {
constructor(props) {
super(props);
this.state = { screenshotImage: null };
this.onDragEvent = this.onDragEvent.bind(this);
this.onKeyPress = this.onKeyPress.bind(this);
}
/*
* Helper to determine whether the drop zone should allow a drop. We only allow
* dropping top sites for now. We don't allow dropping on sponsored top sites
* or the add shortcut button as their position is fixed.
*/
_allowDrop(e) {
if (!e.dataTransfer.types.includes("text/topsite-index")) {
return false;
}
// Grouped reorder is bounded to the pinned block: only pinned tiles are
// valid drop targets, so a tile can't be dropped out into the frecent area.
if (this.props.groupedPinsEnabled) {
return !!this.props.link.isPinned;
}
return this.dragged || !isSponsored(this.props.link);
}
onDragEvent(event) {
switch (event.type) {
case "click":
// Stop any link clicks if we started any dragging
if (this.dragged) {
event.preventDefault();
}
break;
case "dragstart":
event.target.blur();
if (isSponsored(this.props.link)) {
event.preventDefault();
break;
}
this.dragged = true;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/topsite-index", this.props.index);
if (this.props.groupedPinsEnabled) {
// Lift a styled clone (rotated, hover-sized) as the drag image.
this._dragGhost = createDragGhost(event, event.currentTarget);
}
this.props.onDragEvent(
event,
this.props.index,
this.props.link,
this.props.title
);
break;
case "dragend":
this._dragGhost?.destroy();
this._dragGhost = null;
this.props.onDragEvent(event);
break;
case "dragenter":
case "dragover":
case "drop":
if (this._allowDrop(event)) {
event.preventDefault();
this.props.onDragEvent(event, this.props.index);
}
break;
case "mousedown":
// Block the scroll wheel from appearing for middle clicks on search top sites
if (event.button === 1 && this.props.link.searchTopSite) {
event.preventDefault();
}
// Reset at the first mouse event of a potential drag
this.dragged = false;
break;
}
}
static getDerivedStateFromProps(nextProps, prevState) {
const { screenshot } = nextProps.link;
const imageInState = ScreenshotUtils.isRemoteImageLocal(
prevState.screenshotImage,
screenshot
);
if (imageInState) {
return null;
}
// Since image was updated, attempt to revoke old image blob URL, if it exists.
ScreenshotUtils.maybeRevokeBlobObjectURL(prevState.screenshotImage);
return {
screenshotImage: ScreenshotUtils.createLocalImageObject(screenshot),
};
}
componentWillUnmount() {
ScreenshotUtils.maybeRevokeBlobObjectURL(this.state.screenshotImage);
}
onKeyPress(event) {
// If we have tabbed to a search shortcut top site, and we click 'enter',
// we should execute the onClick function. This needs to be added because
// search top sites are anchor tags without an href. See bug 1483135
if (event.key === "Enter" && this.props.link.searchTopSite) {
this.props.onClick(event);
}
}
/*
* Takes the url as a string, runs it through a simple (non-secure) hash turning it into a random number
* Apply that random number to the color array. The same url will always generate the same color.
*/
generateColor() {
let { title, colors } = this.props;
if (!colors) {
return "";
}
let colorArray = colors.split(",");
const hashStr = str => {
let hash = 0;
for (let i = 0; i < str.length; i++) {
let charCode = str.charCodeAt(i);
hash += charCode;
}
return hash;
};
let hash = hashStr(title);
let index = hash % colorArray.length;
return colorArray[index];
}
calculateStyle() {
const { defaultStyle, link } = this.props;
const { tippyTopIcon, faviconSize } = link;
let imageClassName;
let imageStyle;
let showSmallFavicon = false;
let smallFaviconStyle;
let hasScreenshotImage =
this.state.screenshotImage && this.state.screenshotImage.url;
let selectedColor;
if (defaultStyle) {
// force no styles (letter fallback) even if the link has imagery
selectedColor = this.generateColor();
} else if (link.searchTopSite) {
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: `url(${tippyTopIcon})`,
};
smallFaviconStyle = { backgroundImage: `url(${tippyTopIcon})` };
} else if (link.customScreenshotURL) {
// assume high quality custom screenshot and use rich icon styles and class names
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: hasScreenshotImage
? `url(${this.state.screenshotImage.url})`
: "",
};
} else if (
tippyTopIcon ||
link.type === SPOC_TYPE ||
faviconSize >= MIN_RICH_FAVICON_SIZE
) {
// styles and class names for top sites with rich icons
imageClassName = "top-site-icon rich-icon";
imageStyle = {
backgroundColor: link.backgroundColor,
backgroundImage: `url(${tippyTopIcon || link.favicon})`,
};
} else if (faviconSize >= MIN_SMALL_FAVICON_SIZE) {
showSmallFavicon = true;
smallFaviconStyle = { backgroundImage: `url(${link.favicon})` };
} else {
selectedColor = this.generateColor();
imageClassName = "";
}
return {
showSmallFavicon,
smallFaviconStyle,
imageStyle,
imageClassName,
selectedColor,
};
}
render() {
const {
children,
className,
isDraggable,
link,
onClick,
title,
visibleTopSites,
} = this.props;
const topSiteOuterClassName = `top-site-outer${
className ? ` ${className}` : ""
}${link.isDragged ? " dragged" : ""}${
link.isCollapsed ? " collapsed" : ""
}${link.searchTopSite ? " search-shortcut" : ""}`;
const [letterFallback] = title;
const {
showSmallFavicon,
smallFaviconStyle,
imageStyle,
imageClassName,
selectedColor,
} = this.calculateStyle();
const addPinnedTitlel10n = {
"data-l10n-id": "topsite-label-pinned",
"data-l10n-args": JSON.stringify({ title }),
};
let draggableProps = {};
if (isDraggable) {
draggableProps = {
onClick: this.onDragEvent,
onDragEnd: this.onDragEvent,
onDragStart: this.onDragEvent,
onMouseDown: this.onDragEvent,
};
}
let impressionStats = null;
if (link.type === SPOC_TYPE) {
// Record impressions for Pocket tiles.
impressionStats = (
);
} else if (isSponsored(link)) {
// Record impressions for non-Pocket sponsored tiles.
impressionStats = (
);
} else {
// Record impressions for organic tiles.
impressionStats = (
);
}
return (
{this.props.addButton}
);
}
}
TopSiteLink.defaultProps = {
title: "",
link: {},
isDraggable: true,
};
export class TopSite extends React.PureComponent {
constructor(props) {
super(props);
this.state = { showContextMenu: false };
this.onLinkClick = this.onLinkClick.bind(this);
this.onMenuUpdate = this.onMenuUpdate.bind(this);
}
/**
* Report to telemetry additional information about the item.
*/
_getTelemetryInfo() {
const value = { icon_type: this.props.link.iconType };
// Filter out "not_pinned" type for being the default
if (this.props.link.isPinned) {
value.card_type = "pinned";
}
if (this.props.link.searchTopSite) {
// Set the card_type as "search" regardless of its pinning status
value.card_type = "search";
value.search_vendor = this.props.link.hostname;
}
if (isSponsored(this.props.link)) {
value.card_type = "spoc";
}
return { value };
}
userEvent(event) {
this.props.dispatch(
ac.UserEvent(
Object.assign(
{
event,
source: TOP_SITES_SOURCE,
action_position: this.props.index,
},
this._getTelemetryInfo()
)
)
);
}
onLinkClick(event) {
this.userEvent("CLICK");
// Specially handle a top site link click for "typed" frecency bonus as
// specified as a property on the link.
event.preventDefault();
const { altKey, button, ctrlKey, metaKey, shiftKey } = event;
if (!this.props.link.searchTopSite) {
this.props.dispatch(
ac.OnlyToMain({
type: at.OPEN_LINK,
data: Object.assign(this.props.link, {
event: { altKey, button, ctrlKey, metaKey, shiftKey },
is_sponsored: !!this.props.link.sponsored_tile_id,
}),
})
);
if (this.props.link.type === SPOC_TYPE) {
// Record a Pocket-specific click.
this.props.dispatch(
ac.ImpressionStats({
source: TOP_SITES_SOURCE,
click: 0,
tiles: [
{
id: this.props.link.id,
pos: this.props.link.pos,
shim: this.props.link.shim && this.props.link.shim.click,
},
],
})
);
// Record a click for a Pocket sponsored tile.
// This first event is for the shim property
// and is used by our ad service provider.
this.props.dispatch(
ac.DiscoveryStreamUserEvent({
event: "CLICK",
source: TOP_SITES_SOURCE,
action_position: this.props.link.pos,
value: {
card_type: "spoc",
tile_id: this.props.link.id,
shim: this.props.link.shim && this.props.link.shim.click,
attribution: this.props.link.attribution,
},
})
);
// A second event is recoded for internal usage.
const title = this.props.link.label || this.props.link.hostname;
this.props.dispatch(
ac.OnlyToMain({
type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
data: {
type: "click",
position: this.props.link.pos,
tile_id: this.props.link.id,
advertiser: title.toLocaleLowerCase(),
source: NEWTAB_SOURCE,
attribution: this.props.link.attribution,
},
})
);
} else if (isSponsored(this.props.link)) {
// Record a click for a non-Pocket sponsored tile.
const title = this.props.link.label || this.props.link.hostname;
this.props.dispatch(
ac.OnlyToMain({
type: at.TOP_SITES_SPONSORED_IMPRESSION_STATS,
data: {
type: "click",
position: this.props.index,
tile_id: this.props.link.sponsored_tile_id || -1,
reporting_url: this.props.link.sponsored_click_url,
advertiser: title.toLocaleLowerCase(),
source: NEWTAB_SOURCE,
visible_topsites: this.props.visibleTopSites,
frecency_boosted: this.props.link.type === "frecency-boost",
attribution: this.props.link.attribution,
},
})
);
} else {
// Record a click for an organic tile.
this.props.dispatch(
ac.OnlyToMain({
type: at.TOP_SITES_ORGANIC_IMPRESSION_STATS,
data: {
type: "click",
position: this.props.index,
source: NEWTAB_SOURCE,
isPinned: this.props.link.isPinned,
guid: this.props.link.guid,
visible_topsites: this.props.visibleTopSites,
smartScores: this.props.link.scores,
smartWeights: this.props.link.weights,
},
})
);
}
if (this.props.link.sendAttributionRequest) {
this.props.dispatch(
ac.OnlyToMain({
type: at.PARTNER_LINK_ATTRIBUTION,
data: {
targetURL: this.props.link.url,
source: "newtab",
},
})
);
}
} else {
this.props.dispatch(
ac.OnlyToMain({
type: at.FILL_SEARCH_TERM,
data: { label: this.props.link.label },
})
);
}
}
onMenuUpdate(isOpen) {
if (isOpen) {
this.props.onActivate(this.props.index);
} else {
this.props.onActivate();
}
}
render() {
const { props } = this;
const { link } = props;
const isContextMenuOpen = props.activeIndex === props.index;
const title = link.label || link.title || link.hostname;
let menuOptions;
if (link.sponsored_position) {
menuOptions = TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS;
} else if (link.searchTopSite) {
menuOptions = TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS;
} else if (link.type === SPOC_TYPE) {
menuOptions = TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS;
} else {
menuOptions = TOP_SITES_CONTEXT_MENU_OPTIONS;
}
return (
);
}
}
TopSite.defaultProps = {
link: {},
onActivate() {},
};
export class TopSiteAddButton extends React.PureComponent {
constructor(props) {
super(props);
this.onEditButtonClick = this.onEditButtonClick.bind(this);
}
onEditButtonClick() {
this.props.dispatch({
type: at.TOP_SITES_EDIT,
data: { index: this.props.index },
});
}
render() {
// In-grid buttons are large to match the tile icons; the full-row hover
// overlay uses the default size. Both variants participate in the shortcuts'
// arrow-key navigation.
const button = (
);
if (this.props.inGrid) {
return (
{button}
);
}
// For a full row, overlay the button on the last tile; reveal on hover or
// when the shortcuts row has focus.
return {button}
;
}
}
export class TopSitePlaceholder extends React.PureComponent {
render() {
return (
);
}
}
// The classic path renders this mode-agnostically. The grouped-pins path extends
// it through an explicit, opt-in prop contract (all inert when groupedPinsEnabled
// is false), kept here intentionally rather than composed via children/render-prop:
// - groupedPinsEnabled: turns on the grouped-mode behavior below
// - listProps / dropsOnList: zero-pin variant makes the whole one drop target
// - decorations: zero-pin placeholder slot (zeroPinSlot/overZeroPin/setZeroPinRef)
// - listRef: hands the back for the zero-pin drop geometry
// - PinnedAreaOverlay (rendered inside the ) self-measures .pinned-cell tiles
// Longer term these could be composed from the container (children/render-prop)
// so this list goes back to being fully mode-agnostic.
export class _TopSiteList extends React.PureComponent {
static get DEFAULT_STATE() {
return {
activeIndex: null,
focusedIndex: 0,
};
}
constructor(props) {
super(props);
this.state = _TopSiteList.DEFAULT_STATE;
this.onActivate = this.onActivate.bind(this);
this.onWrapperFocus = this.onWrapperFocus.bind(this);
this.onTopsiteFocus = this.onTopsiteFocus.bind(this);
this.onWrapperBlur = this.onWrapperBlur.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onListDragLeave = this.onListDragLeave.bind(this);
this.onListDragOver = this.onListDragOver.bind(this);
}
componentDidUpdate(prevProps) {
// Drag state lives in the hook now; mirror the old reset of our own view
// state (menu + focus) off the dragged-site signal.
const started = !prevProps.draggedSite && this.props.draggedSite;
const ended = prevProps.draggedSite && !this.props.draggedSite;
if (started || ended) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState(ended ? _TopSiteList.DEFAULT_STATE : { activeIndex: null });
}
// Keyboard focus: the shortcuts share one roving tab stop (focusedIndex). If
// the row count shrinks, the focused tile may no longer be rendered, leaving
// the row with no tab stop — reset to the first tile to keep the row focusable.
if (prevProps.TopSitesRows !== this.props.TopSitesRows) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ focusedIndex: 0 });
}
}
onActivate(index) {
this.setState({ activeIndex: index });
}
onKeyDown(e) {
if (this.state.activeIndex || this.state.activeIndex === 0) {
return;
}
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") {
return;
}
// Arrow direction should match visual navigation direction in RTL
const isRTL = document.dir === "rtl";
const navigateToPrevious = isRTL
? e.key === "ArrowRight"
: e.key === "ArrowLeft";
// Walk a flat, DOM-ordered list of focus targets: each tile's link plus
// the add-button.
const focusTargets = [...this.focusRef.querySelectorAll("a, .add-button")];
const currentIndex = focusTargets.indexOf(e.target);
if (currentIndex === -1) {
return;
}
// Wrap around the row: stepping forward past the last target returns to the
// first, and vice versa. The extra `+ count` keeps the modulo positive when
// wrapping backward from index 0.
const count = focusTargets.length;
const delta = navigateToPrevious ? -1 : 1;
const target = focusTargets[(currentIndex + delta + count) % count];
if (target) {
target.tabIndex = 0;
target.focus();
}
}
onWrapperFocus() {
this.focusRef?.addEventListener("keydown", this.onKeyDown);
}
onWrapperBlur() {
this.focusRef?.removeEventListener("keydown", this.onKeyDown);
}
onTopsiteFocus(focusIndex) {
this.setState(() => ({
focusedIndex: focusIndex,
}));
}
// dragover fires continuously on whatever's under the cursor, so it's the
// reliable "current element" signal (dragenter/dragleave order can't be
// trusted between adjacent tiles). The reflow should only live while the
// cursor is within the pinned drop region (the purple outline), so hit-test
// the live overlay boxes: each box spans a whole pinned row, so crossing the
// gaps between pins stays inside and doesn't flicker. Classic has no pinned
// region, so it keeps its original behavior.
onListDragOver(event) {
// Preserve any list-level handler (zero-pin grouped drop geometry).
this.props.listProps?.onDragOver?.(event);
if (!this.props.groupedPinsEnabled) {
return;
}
const boxes = [...event.currentTarget.querySelectorAll(".pinned-drop-box")];
if (!boxes.length) {
return;
}
const inside = boxes.some(box => {
const r = box.getBoundingClientRect();
return (
event.clientX >= r.left &&
event.clientX <= r.right &&
event.clientY >= r.top &&
event.clientY <= r.bottom
);
});
if (!inside) {
this.props.onDragEvent(event);
}
}
// Safety net for the grouped pinned-region clear: leaving the grid straight
// off an edge tile, where no in-region dragover lands first. Filters out
// tile-to-tile crossings via relatedTarget. Grouped-only, so classic keeps its
// original "placeholder persists until drop/dragend" behavior.
onListDragLeave(event) {
// Preserve any list-level handler (e.g. zero-pin grouped drop).
this.props.listProps?.onDragLeave?.(event);
if (!this.props.groupedPinsEnabled) {
return;
}
if (!event.currentTarget.contains(event.relatedTarget)) {
this.props.onDragEvent(event);
}
}
render() {
const { props } = this;
const topSites = this.props.sites;
const maxSitesPerRow =
props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW;
// The array can be sparse (e.g. dismissed tiles leave holes), so count the
// tiles that actually render rather than relying on slot indices.
const tileCount = topSites.filter(site => site && !site.isAddButton).length;
const rowFull = tileCount > 0 && tileCount % maxSitesPerRow === 0;
const topSitesUI = [];
const commonProps = {
onDragEvent: this.props.onDragEvent,
dispatch: props.dispatch,
groupedPinsEnabled: this.props.groupedPinsEnabled,
// Zero-pin drops on the list (single synthetic target, no per-tile
// handlers). The reorder+append path keeps per-tile handlers and adds a
// list-level append target, so it passes listProps without this flag.
dropsOnList: !!this.props.dropsOnList,
};
const { decorations } = this.props;
// We assign a key to each placeholder slot. We need it to be independent
// of the slot index (i below) so that the keys used stay the same during
// drag and drop reordering and the underlying DOM nodes are reused.
// This mostly (only?) affects linux so be sure to test on linux before changing.
let holeIndex = 0;
// On narrow viewports, we only show 6 sites per row. We'll mark the rest as
// .hide-for-narrow to hide in CSS via @media query.
const novaEnabled = this.props.Prefs.values["nova.enabled"];
const maxNarrowVisibleIndex = props.TopSitesRows * 6;
const maxSmallVisibleIndex = props.TopSitesRows * 8;
for (let i = 0, l = topSites.length; i < l; i++) {
// Zero-pin grouped drag: with no pins there are no slots to reorder, so
// open the pin area with one placeholder at the first pinnable slot.
if (decorations && i === decorations.zeroPinSlot) {
topSitesUI.push(
);
}
const link =
topSites[i] &&
Object.assign({}, topSites[i], {
iconType: this.props.topSiteIconType(topSites[i]),
});
const slotProps = {
// Stable key so the button isn't remounted (and re-flashed) as its slot
// shifts on pin/unpin — it has no url to key off of.
key: link?.isAddButton
? "add-button"
: link?.url || `hole-${holeIndex++}`,
index: i,
};
// @nova-cleanup(remove-conditional): Remove classic path once Nova ships
if (novaEnabled) {
if (i >= maxSmallVisibleIndex) {
slotProps.className = "nova-hide-for-s";
} else if (i >= maxNarrowVisibleIndex) {
slotProps.className = "nova-hide-for-xs";
}
} else if (i >= maxSmallVisibleIndex) {
slotProps.className = "hide-for-small";
} else if (i >= maxNarrowVisibleIndex) {
slotProps.className = "hide-for-narrow";
}
// Marker (no styles) the drop-zone overlay measures to size the box. The
// preview marks a joining frecent isPinned, so the box grows with it.
if (this.props.groupedPinsEnabled && link?.isPinned) {
slotProps.className = `${
slotProps.className ? `${slotProps.className} ` : ""
}pinned-cell`;
}
const { key: slotKey, ...restSlotProps } = slotProps;
let topSiteLink = null;
// Use a placeholder if the link is empty or it's rendering a sponsored
// tile for the about:home startup cache.
if (
!link ||
(props.App.isForStartupCache.TopSites && isSponsored(link))
) {
if (link) {
topSiteLink = (
);
}
} else if (topSites[i]?.isAddButton) {
// Render the add button in-grid when the row isn't full. It carries the
// slot's responsive hide class so it drops at the same breakpoints as a
// tile in that column would.
if (!rowFull) {
topSiteLink = (
{
this.onTopsiteFocus(i);
}}
/>
);
}
} else {
// When this is the last tile of a full row, the add button has no free
// cell of its own, so render it as a hover overlay anchored to this tile.
let addButton = null;
if (topSites[i + 1]?.isAddButton && rowFull) {
addButton = (
{
this.onTopsiteFocus(i + 1);
}}
/>
);
}
topSiteLink = (
{
this.onTopsiteFocus(i);
}}
visibleTopSites={this.props.visibleTopSites}
addButton={addButton}
/>
);
}
// Skip empty slots — topSiteLink is null when there's no link and no placeholder.
if (topSiteLink) {
topSitesUI.push(topSiteLink);
}
}
return (
{
this.focusRef = el;
this.props.listRef?.(el);
}}
className={`top-sites-list${
this.props.draggedSite ? " dnd-active" : ""
}`}
style={{
"--top-sites-max-per-row":
this.props.topSitesMaxSitesPerRow ?? TOP_SITES_MAX_SITES_PER_ROW,
}}
>
{topSitesUI}
{this.props.groupedPinsEnabled && (
)}
);
}
}
export const TopSiteList = connect(state => ({
App: state.App,
Prefs: state.Prefs,
}))(_TopSiteList);