summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src/components/TopSites
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/content-src/components/TopSites')
-rw-r--r--browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx192
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSite.jsx873
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx323
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx111
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx149
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSites.jsx213
-rw-r--r--browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js39
-rw-r--r--browser/components/newtab/content-src/components/TopSites/_TopSites.scss628
8 files changed, 2528 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx
new file mode 100644
index 0000000000..4324c019f6
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/SearchShortcutsForm.jsx
@@ -0,0 +1,192 @@
+/* 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.sys.mjs";
+import React from "react";
+import { TOP_SITES_SOURCE } from "./TopSitesConstants";
+
+export class SelectableSearchShortcut extends React.PureComponent {
+ render() {
+ const { shortcut, selected } = this.props;
+ const imageStyle = { backgroundImage: `url("${shortcut.tippyTopIcon}")` };
+ return (
+ <div className="top-site-outer search-shortcut">
+ <input
+ type="checkbox"
+ id={shortcut.keyword}
+ name={shortcut.keyword}
+ checked={selected}
+ onChange={this.props.onChange}
+ />
+ <label htmlFor={shortcut.keyword}>
+ <div className="top-site-inner">
+ <span>
+ <div className="tile">
+ <div
+ className="top-site-icon rich-icon"
+ style={imageStyle}
+ data-fallback="@"
+ />
+ <div className="top-site-icon search-topsite" />
+ </div>
+ <div className="title">
+ <span dir="auto">{shortcut.keyword}</span>
+ </div>
+ </span>
+ </div>
+ </label>
+ </div>
+ );
+ }
+}
+
+export class SearchShortcutsForm extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleChange = this.handleChange.bind(this);
+ this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+ this.onSaveButtonClick = this.onSaveButtonClick.bind(this);
+
+ // clone the shortcuts and add them to the state so we can add isSelected property
+ const shortcuts = [];
+ const { rows, searchShortcuts } = props.TopSites;
+ searchShortcuts.forEach(shortcut => {
+ shortcuts.push({
+ ...shortcut,
+ isSelected: !!rows.find(
+ row =>
+ row &&
+ row.isPinned &&
+ row.searchTopSite &&
+ row.label === shortcut.keyword
+ ),
+ });
+ });
+ this.state = { shortcuts };
+ }
+
+ handleChange(event) {
+ const { target } = event;
+ const { name, checked } = target;
+ this.setState(prevState => {
+ const shortcuts = prevState.shortcuts.slice();
+ let shortcut = shortcuts.find(({ keyword }) => keyword === name);
+ shortcut.isSelected = checked;
+ return { shortcuts };
+ });
+ }
+
+ onCancelButtonClick(ev) {
+ ev.preventDefault();
+ this.props.onClose();
+ }
+
+ onSaveButtonClick(ev) {
+ ev.preventDefault();
+
+ // Check if there were any changes and act accordingly
+ const { rows } = this.props.TopSites;
+ const pinQueue = [];
+ const unpinQueue = [];
+ this.state.shortcuts.forEach(shortcut => {
+ const alreadyPinned = rows.find(
+ row =>
+ row &&
+ row.isPinned &&
+ row.searchTopSite &&
+ row.label === shortcut.keyword
+ );
+ if (shortcut.isSelected && !alreadyPinned) {
+ pinQueue.push(this._searchTopSite(shortcut));
+ } else if (!shortcut.isSelected && alreadyPinned) {
+ unpinQueue.push({
+ url: alreadyPinned.url,
+ searchVendor: shortcut.shortURL,
+ });
+ }
+ });
+
+ // Tell the feed to do the work.
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: at.UPDATE_PINNED_SEARCH_SHORTCUTS,
+ data: {
+ addedShortcuts: pinQueue,
+ deletedShortcuts: unpinQueue,
+ },
+ })
+ );
+
+ // Send the Telemetry pings.
+ pinQueue.forEach(shortcut => {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_ADD",
+ value: { search_vendor: shortcut.searchVendor },
+ })
+ );
+ });
+ unpinQueue.forEach(shortcut => {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_DELETE",
+ value: { search_vendor: shortcut.searchVendor },
+ })
+ );
+ });
+
+ this.props.onClose();
+ }
+
+ _searchTopSite(shortcut) {
+ return {
+ url: shortcut.url,
+ searchTopSite: true,
+ label: shortcut.keyword,
+ searchVendor: shortcut.shortURL,
+ };
+ }
+
+ render() {
+ return (
+ <form className="topsite-form">
+ <div className="search-shortcuts-container">
+ <h3
+ className="section-title grey-title"
+ data-l10n-id="newtab-topsites-add-search-engine-header"
+ />
+ <div>
+ {this.state.shortcuts.map(shortcut => (
+ <SelectableSearchShortcut
+ key={shortcut.keyword}
+ shortcut={shortcut}
+ selected={shortcut.isSelected}
+ onChange={this.handleChange}
+ />
+ ))}
+ </div>
+ </div>
+ <section className="actions">
+ <button
+ className="cancel"
+ type="button"
+ onClick={this.onCancelButtonClick}
+ data-l10n-id="newtab-topsites-cancel-button"
+ />
+ <button
+ className="done"
+ type="submit"
+ onClick={this.onSaveButtonClick}
+ data-l10n-id="newtab-topsites-save-button"
+ />
+ </section>
+ </form>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSite.jsx b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
new file mode 100644
index 0000000000..90641008be
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSite.jsx
@@ -0,0 +1,873 @@
+/* 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.sys.mjs";
+import {
+ MIN_RICH_FAVICON_SIZE,
+ MIN_SMALL_FAVICON_SIZE,
+ 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 { 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 SPOC_TYPE = "SPOC";
+const NEWTAB_SOURCE = "newtab";
+
+// For cases if we want to know if this is sponsored by either sponsored_position or type.
+// We have two sources for sponsored topsites, and
+// sponsored_position is set by one sponsored source, and type is set by another.
+// This is not called in all cases, sometimes we want to know if it's one source
+// or the other. This function is only applicable in cases where we only care if it's either.
+function isSponsored(link) {
+ return link?.sponsored_position || link?.type === SPOC_TYPE;
+}
+
+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
+ * as their position is fixed.
+ */
+ _allowDrop(e) {
+ return (
+ (this.dragged || !isSponsored(this.props.link)) &&
+ e.dataTransfer.types.includes("text/topsite-index")
+ );
+ }
+
+ 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);
+ this.props.onDragEvent(
+ event,
+ this.props.index,
+ this.props.link,
+ this.props.title
+ );
+ break;
+ case "dragend":
+ 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;
+ }
+ }
+
+ /**
+ * Helper to obtain the next state based on nextProps and prevState.
+ *
+ * NOTE: Rename this method to getDerivedStateFromProps when we update React
+ * to >= 16.3. We will need to update tests as well. We cannot rename this
+ * method to getDerivedStateFromProps now because there is a mismatch in
+ * the React version that we are using for both testing and production.
+ * (i.e. react-test-render => "16.3.2", react => "16.2.0").
+ *
+ * See https://github.com/airbnb/enzyme/blob/master/packages/enzyme-adapter-react-16/package.json#L43.
+ */
+ static getNextStateFromProps(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),
+ };
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillMount() {
+ const nextState = TopSiteLink.getNextStateFromProps(this.props, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ // NOTE: Remove this function when we update React to >= 16.3 since React will
+ // call getDerivedStateFromProps automatically. We will also need to
+ // rename getNextStateFromProps to getDerivedStateFromProps.
+ componentWillReceiveProps(nextProps) {
+ const nextState = TopSiteLink.getNextStateFromProps(nextProps, this.state);
+ if (nextState) {
+ this.setState(nextState);
+ }
+ }
+
+ 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 (this.props.link.searchTopSite && event.key === "Enter") {
+ 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 } =
+ this.props;
+ const topSiteOuterClassName = `top-site-outer${
+ className ? ` ${className}` : ""
+ }${link.isDragged ? " dragged" : ""}${
+ link.searchTopSite ? " search-shortcut" : ""
+ }`;
+ const [letterFallback] = title;
+ const {
+ showSmallFavicon,
+ smallFaviconStyle,
+ imageStyle,
+ imageClassName,
+ selectedColor,
+ } = this.calculateStyle();
+
+ 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 = (
+ <ImpressionStats
+ flightId={link.flightId}
+ rows={[
+ {
+ id: link.id,
+ pos: link.pos,
+ shim: link.shim && link.shim.impression,
+ advertiser: title.toLocaleLowerCase(),
+ },
+ ]}
+ dispatch={this.props.dispatch}
+ source={TOP_SITES_SOURCE}
+ />
+ );
+ } else if (isSponsored(link)) {
+ // Record impressions for non-Pocket sponsored tiles.
+ impressionStats = (
+ <TopSiteImpressionWrapper
+ actionType={at.TOP_SITES_SPONSORED_IMPRESSION_STATS}
+ tile={{
+ position: this.props.index,
+ tile_id: link.sponsored_tile_id || -1,
+ reporting_url: link.sponsored_impression_url,
+ advertiser: title.toLocaleLowerCase(),
+ source: NEWTAB_SOURCE,
+ }}
+ // For testing.
+ IntersectionObserver={this.props.IntersectionObserver}
+ document={this.props.document}
+ dispatch={this.props.dispatch}
+ />
+ );
+ } else {
+ // Record impressions for organic tiles.
+ impressionStats = (
+ <TopSiteImpressionWrapper
+ actionType={at.TOP_SITES_ORGANIC_IMPRESSION_STATS}
+ tile={{
+ position: this.props.index,
+ source: NEWTAB_SOURCE,
+ }}
+ // For testing.
+ IntersectionObserver={this.props.IntersectionObserver}
+ document={this.props.document}
+ dispatch={this.props.dispatch}
+ />
+ );
+ }
+
+ return (
+ <li
+ className={topSiteOuterClassName}
+ onDrop={this.onDragEvent}
+ onDragOver={this.onDragEvent}
+ onDragEnter={this.onDragEvent}
+ onDragLeave={this.onDragEvent}
+ {...draggableProps}
+ >
+ <div className="top-site-inner">
+ {/* We don't yet support an accessible drag-and-drop implementation, see Bug 1552005 */}
+ {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */}
+ <a
+ className="top-site-button"
+ href={link.searchTopSite ? undefined : link.url}
+ tabIndex="0"
+ onKeyPress={this.onKeyPress}
+ onClick={onClick}
+ draggable={true}
+ data-is-sponsored-link={!!link.sponsored_tile_id}
+ >
+ <div className="tile" aria-hidden={true}>
+ <div
+ className={
+ selectedColor
+ ? "icon-wrapper letter-fallback"
+ : "icon-wrapper"
+ }
+ data-fallback={letterFallback}
+ style={selectedColor ? { backgroundColor: selectedColor } : {}}
+ >
+ <div className={imageClassName} style={imageStyle} />
+ {showSmallFavicon && (
+ <div
+ className="top-site-icon default-icon"
+ data-fallback={smallFaviconStyle ? "" : letterFallback}
+ style={smallFaviconStyle}
+ />
+ )}
+ </div>
+ {link.searchTopSite && (
+ <div className="top-site-icon search-topsite" />
+ )}
+ </div>
+ <div
+ className={`title${link.isPinned ? " has-icon pinned" : ""}${
+ link.type === SPOC_TYPE || link.show_sponsored_label
+ ? " sponsored"
+ : ""
+ }`}
+ >
+ <span dir="auto">
+ {link.isPinned && <div className="icon icon-pin-small" />}
+ {title || <br />}
+ <span
+ className="sponsored-label"
+ data-l10n-id="newtab-topsite-sponsored"
+ />
+ </span>
+ </div>
+ </a>
+ {children}
+ {impressionStats}
+ </div>
+ </li>
+ );
+ }
+}
+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 },
+ }),
+ })
+ );
+
+ 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.
+ 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,
+ },
+ })
+ );
+ } 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,
+ },
+ })
+ );
+ } 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,
+ },
+ })
+ );
+ }
+
+ 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.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 (
+ <TopSiteLink
+ {...props}
+ onClick={this.onLinkClick}
+ onDragEvent={this.props.onDragEvent}
+ className={`${props.className || ""}${
+ isContextMenuOpen ? " active" : ""
+ }`}
+ title={title}
+ >
+ <div>
+ <ContextMenuButton
+ tooltip="newtab-menu-content-tooltip"
+ tooltipArgs={{ title }}
+ onUpdate={this.onMenuUpdate}
+ >
+ <LinkMenu
+ dispatch={props.dispatch}
+ index={props.index}
+ onUpdate={this.onMenuUpdate}
+ options={menuOptions}
+ site={link}
+ shouldSendImpressionStats={link.type === SPOC_TYPE}
+ siteInfo={this._getTelemetryInfo()}
+ source={TOP_SITES_SOURCE}
+ />
+ </ContextMenuButton>
+ </div>
+ </TopSiteLink>
+ );
+ }
+}
+TopSite.defaultProps = {
+ link: {},
+ onActivate() {},
+};
+
+export class TopSitePlaceholder 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() {
+ return (
+ <TopSiteLink
+ {...this.props}
+ className={`placeholder ${this.props.className || ""}`}
+ isDraggable={false}
+ >
+ <button
+ aria-haspopup="dialog"
+ className="context-menu-button edit-button icon"
+ data-l10n-id="newtab-menu-topsites-placeholder-tooltip"
+ onClick={this.onEditButtonClick}
+ />
+ </TopSiteLink>
+ );
+ }
+}
+
+export class _TopSiteList extends React.PureComponent {
+ static get DEFAULT_STATE() {
+ return {
+ activeIndex: null,
+ draggedIndex: null,
+ draggedSite: null,
+ draggedTitle: null,
+ topSitesPreview: null,
+ };
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = _TopSiteList.DEFAULT_STATE;
+ this.onDragEvent = this.onDragEvent.bind(this);
+ this.onActivate = this.onActivate.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (this.state.draggedSite) {
+ const prevTopSites = this.props.TopSites && this.props.TopSites.rows;
+ const newTopSites = nextProps.TopSites && nextProps.TopSites.rows;
+ if (
+ prevTopSites &&
+ prevTopSites[this.state.draggedIndex] &&
+ prevTopSites[this.state.draggedIndex].url ===
+ this.state.draggedSite.url &&
+ (!newTopSites[this.state.draggedIndex] ||
+ newTopSites[this.state.draggedIndex].url !==
+ this.state.draggedSite.url)
+ ) {
+ // We got the new order from the redux store via props. We can clear state now.
+ this.setState(_TopSiteList.DEFAULT_STATE);
+ }
+ }
+ }
+
+ userEvent(event, index) {
+ this.props.dispatch(
+ ac.UserEvent({
+ event,
+ source: TOP_SITES_SOURCE,
+ action_position: index,
+ })
+ );
+ }
+
+ onDragEvent(event, index, link, title) {
+ switch (event.type) {
+ case "dragstart":
+ this.dropped = false;
+ this.setState({
+ draggedIndex: index,
+ draggedSite: link,
+ draggedTitle: title,
+ activeIndex: null,
+ });
+ this.userEvent("DRAG", index);
+ break;
+ case "dragend":
+ if (!this.dropped) {
+ // If there was no drop event, reset the state to the default.
+ this.setState(_TopSiteList.DEFAULT_STATE);
+ }
+ break;
+ case "dragenter":
+ if (index === this.state.draggedIndex) {
+ this.setState({ topSitesPreview: null });
+ } else {
+ this.setState({
+ topSitesPreview: this._makeTopSitesPreview(index),
+ });
+ }
+ break;
+ case "drop":
+ if (index !== this.state.draggedIndex) {
+ this.dropped = true;
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.TOP_SITES_INSERT,
+ data: {
+ site: {
+ url: this.state.draggedSite.url,
+ label: this.state.draggedTitle,
+ customScreenshotURL:
+ this.state.draggedSite.customScreenshotURL,
+ // Only if the search topsites experiment is enabled
+ ...(this.state.draggedSite.searchTopSite && {
+ searchTopSite: true,
+ }),
+ },
+ index,
+ draggedFromIndex: this.state.draggedIndex,
+ },
+ })
+ );
+ this.userEvent("DROP", index);
+ }
+ break;
+ }
+ }
+
+ _getTopSites() {
+ // Make a copy of the sites to truncate or extend to desired length
+ let topSites = this.props.TopSites.rows.slice();
+ topSites.length = this.props.TopSitesRows * TOP_SITES_MAX_SITES_PER_ROW;
+ return topSites;
+ }
+
+ /**
+ * Make a preview of the topsites that will be the result of dropping the currently
+ * dragged site at the specified index.
+ */
+ _makeTopSitesPreview(index) {
+ const topSites = this._getTopSites();
+ topSites[this.state.draggedIndex] = null;
+ const preview = topSites.map(site =>
+ site && (site.isPinned || isSponsored(site)) ? site : null
+ );
+ const unpinned = topSites.filter(
+ site => site && !site.isPinned && !isSponsored(site)
+ );
+ const siteToInsert = Object.assign({}, this.state.draggedSite, {
+ isPinned: true,
+ isDragged: true,
+ });
+
+ if (!preview[index]) {
+ preview[index] = siteToInsert;
+ } else {
+ // Find the hole to shift the pinned site(s) towards. We shift towards the
+ // hole left by the site being dragged.
+ let holeIndex = index;
+ const indexStep = index > this.state.draggedIndex ? -1 : 1;
+ while (preview[holeIndex]) {
+ holeIndex += indexStep;
+ }
+
+ // Shift towards the hole.
+ const shiftingStep = index > this.state.draggedIndex ? 1 : -1;
+ while (
+ index > this.state.draggedIndex ? holeIndex < index : holeIndex > index
+ ) {
+ let nextIndex = holeIndex + shiftingStep;
+ while (isSponsored(preview[nextIndex])) {
+ nextIndex += shiftingStep;
+ }
+ preview[holeIndex] = preview[nextIndex];
+ holeIndex = nextIndex;
+ }
+ preview[index] = siteToInsert;
+ }
+
+ // Fill in the remaining holes with unpinned sites.
+ for (let i = 0; i < preview.length; i++) {
+ if (!preview[i]) {
+ preview[i] = unpinned.shift() || null;
+ }
+ }
+
+ return preview;
+ }
+
+ onActivate(index) {
+ this.setState({ activeIndex: index });
+ }
+
+ render() {
+ const { props } = this;
+ const topSites = this.state.topSitesPreview || this._getTopSites();
+ const topSitesUI = [];
+ const commonProps = {
+ onDragEvent: this.onDragEvent,
+ dispatch: props.dispatch,
+ };
+ // 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 maxNarrowVisibleIndex = props.TopSitesRows * 6;
+
+ for (let i = 0, l = topSites.length; i < l; i++) {
+ const link =
+ topSites[i] &&
+ Object.assign({}, topSites[i], {
+ iconType: this.props.topSiteIconType(topSites[i]),
+ });
+
+ const slotProps = {
+ key: link ? link.url : holeIndex++,
+ index: i,
+ };
+ if (i >= maxNarrowVisibleIndex) {
+ slotProps.className = "hide-for-narrow";
+ }
+
+ let topSiteLink;
+ // 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 && isSponsored(link))) {
+ topSiteLink = <TopSitePlaceholder {...slotProps} {...commonProps} />;
+ } else {
+ topSiteLink = (
+ <TopSite
+ link={link}
+ activeIndex={this.state.activeIndex}
+ onActivate={this.onActivate}
+ {...slotProps}
+ {...commonProps}
+ colors={props.colors}
+ />
+ );
+ }
+
+ topSitesUI.push(topSiteLink);
+ }
+ return (
+ <ul
+ className={`top-sites-list${
+ this.state.draggedSite ? " dnd-active" : ""
+ }`}
+ >
+ {topSitesUI}
+ </ul>
+ );
+ }
+}
+
+export const TopSiteList = connect(state => ({
+ App: state.App,
+}))(_TopSiteList);
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx
new file mode 100644
index 0000000000..7dd61bdc93
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteForm.jsx
@@ -0,0 +1,323 @@
+/* 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.sys.mjs";
+import { A11yLinkButton } from "content-src/components/A11yLinkButton/A11yLinkButton";
+import React from "react";
+import { TOP_SITES_SOURCE } from "./TopSitesConstants";
+import { TopSiteFormInput } from "./TopSiteFormInput";
+import { TopSiteLink } from "./TopSite";
+
+export class TopSiteForm extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ const { site } = props;
+ this.state = {
+ label: site ? site.label || site.hostname : "",
+ url: site ? site.url : "",
+ validationError: false,
+ customScreenshotUrl: site ? site.customScreenshotURL : "",
+ showCustomScreenshotForm: site ? site.customScreenshotURL : false,
+ };
+ this.onClearScreenshotInput = this.onClearScreenshotInput.bind(this);
+ this.onLabelChange = this.onLabelChange.bind(this);
+ this.onUrlChange = this.onUrlChange.bind(this);
+ this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
+ this.onClearUrlClick = this.onClearUrlClick.bind(this);
+ this.onDoneButtonClick = this.onDoneButtonClick.bind(this);
+ this.onCustomScreenshotUrlChange =
+ this.onCustomScreenshotUrlChange.bind(this);
+ this.onPreviewButtonClick = this.onPreviewButtonClick.bind(this);
+ this.onEnableScreenshotUrlForm = this.onEnableScreenshotUrlForm.bind(this);
+ this.validateUrl = this.validateUrl.bind(this);
+ }
+
+ onLabelChange(event) {
+ this.setState({ label: event.target.value });
+ }
+
+ onUrlChange(event) {
+ this.setState({
+ url: event.target.value,
+ validationError: false,
+ });
+ }
+
+ onClearUrlClick() {
+ this.setState({
+ url: "",
+ validationError: false,
+ });
+ }
+
+ onEnableScreenshotUrlForm() {
+ this.setState({ showCustomScreenshotForm: true });
+ }
+
+ _updateCustomScreenshotInput(customScreenshotUrl) {
+ this.setState({
+ customScreenshotUrl,
+ validationError: false,
+ });
+ this.props.dispatch({ type: at.PREVIEW_REQUEST_CANCEL });
+ }
+
+ onCustomScreenshotUrlChange(event) {
+ this._updateCustomScreenshotInput(event.target.value);
+ }
+
+ onClearScreenshotInput() {
+ this._updateCustomScreenshotInput("");
+ }
+
+ onCancelButtonClick(ev) {
+ ev.preventDefault();
+ this.props.onClose();
+ }
+
+ onDoneButtonClick(ev) {
+ ev.preventDefault();
+
+ if (this.validateForm()) {
+ const site = { url: this.cleanUrl(this.state.url) };
+ const { index } = this.props;
+ if (this.state.label !== "") {
+ site.label = this.state.label;
+ }
+
+ if (this.state.customScreenshotUrl) {
+ site.customScreenshotURL = this.cleanUrl(
+ this.state.customScreenshotUrl
+ );
+ } else if (this.props.site && this.props.site.customScreenshotURL) {
+ // Used to flag that previously cached screenshot should be removed
+ site.customScreenshotURL = null;
+ }
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.TOP_SITES_PIN,
+ data: { site, index },
+ })
+ );
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "TOP_SITES_EDIT",
+ action_position: index,
+ })
+ );
+
+ this.props.onClose();
+ }
+ }
+
+ onPreviewButtonClick(event) {
+ event.preventDefault();
+ if (this.validateForm()) {
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.PREVIEW_REQUEST,
+ data: { url: this.cleanUrl(this.state.customScreenshotUrl) },
+ })
+ );
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "PREVIEW_REQUEST",
+ })
+ );
+ }
+ }
+
+ cleanUrl(url) {
+ // If we are missing a protocol, prepend http://
+ if (!url.startsWith("http:") && !url.startsWith("https:")) {
+ return `http://${url}`;
+ }
+ return url;
+ }
+
+ _tryParseUrl(url) {
+ try {
+ return new URL(url);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ validateUrl(url) {
+ const validProtocols = ["http:", "https:"];
+ const urlObj =
+ this._tryParseUrl(url) || this._tryParseUrl(this.cleanUrl(url));
+
+ return urlObj && validProtocols.includes(urlObj.protocol);
+ }
+
+ validateCustomScreenshotUrl() {
+ const { customScreenshotUrl } = this.state;
+ return !customScreenshotUrl || this.validateUrl(customScreenshotUrl);
+ }
+
+ validateForm() {
+ const validate =
+ this.validateUrl(this.state.url) && this.validateCustomScreenshotUrl();
+
+ if (!validate) {
+ this.setState({ validationError: true });
+ }
+
+ return validate;
+ }
+
+ _renderCustomScreenshotInput() {
+ const { customScreenshotUrl } = this.state;
+ const requestFailed = this.props.previewResponse === "";
+ const validationError =
+ (this.state.validationError && !this.validateCustomScreenshotUrl()) ||
+ requestFailed;
+ // Set focus on error if the url field is valid or when the input is first rendered and is empty
+ const shouldFocus =
+ (validationError && this.validateUrl(this.state.url)) ||
+ !customScreenshotUrl;
+ const isLoading =
+ this.props.previewResponse === null &&
+ customScreenshotUrl &&
+ this.props.previewUrl === this.cleanUrl(customScreenshotUrl);
+
+ if (!this.state.showCustomScreenshotForm) {
+ return (
+ <A11yLinkButton
+ onClick={this.onEnableScreenshotUrlForm}
+ className="enable-custom-image-input"
+ data-l10n-id="newtab-topsites-use-image-link"
+ />
+ );
+ }
+ return (
+ <div className="custom-image-input-container">
+ <TopSiteFormInput
+ errorMessageId={
+ requestFailed
+ ? "newtab-topsites-image-validation"
+ : "newtab-topsites-url-validation"
+ }
+ loading={isLoading}
+ onChange={this.onCustomScreenshotUrlChange}
+ onClear={this.onClearScreenshotInput}
+ shouldFocus={shouldFocus}
+ typeUrl={true}
+ value={customScreenshotUrl}
+ validationError={validationError}
+ titleId="newtab-topsites-image-url-label"
+ placeholderId="newtab-topsites-url-input"
+ />
+ </div>
+ );
+ }
+
+ render() {
+ const { customScreenshotUrl } = this.state;
+ const requestFailed = this.props.previewResponse === "";
+ // For UI purposes, editing without an existing link is "add"
+ const showAsAdd = !this.props.site;
+ const previous =
+ (this.props.site && this.props.site.customScreenshotURL) || "";
+ const changed =
+ customScreenshotUrl && this.cleanUrl(customScreenshotUrl) !== previous;
+ // Preview mode if changes were made to the custom screenshot URL and no preview was received yet
+ // or the request failed
+ const previewMode = changed && !this.props.previewResponse;
+ const previewLink = Object.assign({}, this.props.site);
+ if (this.props.previewResponse) {
+ previewLink.screenshot = this.props.previewResponse;
+ previewLink.customScreenshotURL = this.props.previewUrl;
+ }
+ // Handles the form submit so an enter press performs the correct action
+ const onSubmit = previewMode
+ ? this.onPreviewButtonClick
+ : this.onDoneButtonClick;
+
+ const addTopsitesHeaderL10nId = "newtab-topsites-add-shortcut-header";
+ const editTopsitesHeaderL10nId = "newtab-topsites-edit-shortcut-header";
+ return (
+ <form className="topsite-form" onSubmit={onSubmit}>
+ <div className="form-input-container">
+ <h3
+ className="section-title grey-title"
+ data-l10n-id={
+ showAsAdd ? addTopsitesHeaderL10nId : editTopsitesHeaderL10nId
+ }
+ />
+ <div className="fields-and-preview">
+ <div className="form-wrapper">
+ <TopSiteFormInput
+ onChange={this.onLabelChange}
+ value={this.state.label}
+ titleId="newtab-topsites-title-label"
+ placeholderId="newtab-topsites-title-input"
+ autoFocusOnOpen={true}
+ />
+ <TopSiteFormInput
+ onChange={this.onUrlChange}
+ shouldFocus={
+ this.state.validationError &&
+ !this.validateUrl(this.state.url)
+ }
+ value={this.state.url}
+ onClear={this.onClearUrlClick}
+ validationError={
+ this.state.validationError &&
+ !this.validateUrl(this.state.url)
+ }
+ titleId="newtab-topsites-url-label"
+ typeUrl={true}
+ placeholderId="newtab-topsites-url-input"
+ errorMessageId="newtab-topsites-url-validation"
+ />
+ {this._renderCustomScreenshotInput()}
+ </div>
+ <TopSiteLink
+ link={previewLink}
+ defaultStyle={requestFailed}
+ title={this.state.label}
+ />
+ </div>
+ </div>
+ <section className="actions">
+ <button
+ className="cancel"
+ type="button"
+ onClick={this.onCancelButtonClick}
+ data-l10n-id="newtab-topsites-cancel-button"
+ />
+ {previewMode ? (
+ <button
+ className="done preview"
+ type="submit"
+ data-l10n-id="newtab-topsites-preview-button"
+ />
+ ) : (
+ <button
+ className="done"
+ type="submit"
+ data-l10n-id={
+ showAsAdd
+ ? "newtab-topsites-add-button"
+ : "newtab-topsites-save-button"
+ }
+ />
+ )}
+ </section>
+ </form>
+ );
+ }
+}
+
+TopSiteForm.defaultProps = {
+ site: null,
+ index: -1,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx
new file mode 100644
index 0000000000..c680edc7e4
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteFormInput.jsx
@@ -0,0 +1,111 @@
+/* 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 React from "react";
+
+export class TopSiteFormInput extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = { validationError: this.props.validationError };
+ this.onChange = this.onChange.bind(this);
+ this.onMount = this.onMount.bind(this);
+ this.onClearIconPress = this.onClearIconPress.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.shouldFocus && !this.props.shouldFocus) {
+ this.input.focus();
+ }
+ if (nextProps.validationError && !this.props.validationError) {
+ this.setState({ validationError: true });
+ }
+ // If the component is in an error state but the value was cleared by the parent
+ if (this.state.validationError && !nextProps.value) {
+ this.setState({ validationError: false });
+ }
+ }
+
+ onClearIconPress(event) {
+ // If there is input in the URL or custom image URL fields,
+ // and we hit 'enter' while tabbed over the clear icon,
+ // we should execute the function to clear the field.
+ if (event.key === "Enter") {
+ this.props.onClear();
+ }
+ }
+
+ onChange(ev) {
+ if (this.state.validationError) {
+ this.setState({ validationError: false });
+ }
+ this.props.onChange(ev);
+ }
+
+ onMount(input) {
+ this.input = input;
+ }
+
+ renderLoadingOrCloseButton() {
+ const showClearButton = this.props.value && this.props.onClear;
+
+ if (this.props.loading) {
+ return (
+ <div className="loading-container">
+ <div className="loading-animation" />
+ </div>
+ );
+ } else if (showClearButton) {
+ return (
+ <button
+ type="button"
+ className="icon icon-clear-input icon-button-style"
+ onClick={this.props.onClear}
+ onKeyPress={this.onClearIconPress}
+ />
+ );
+ }
+ return null;
+ }
+
+ render() {
+ const { typeUrl } = this.props;
+ const { validationError } = this.state;
+
+ return (
+ <label>
+ <span data-l10n-id={this.props.titleId} />
+ <div
+ className={`field ${typeUrl ? "url" : ""}${
+ validationError ? " invalid" : ""
+ }`}
+ >
+ <input
+ type="text"
+ value={this.props.value}
+ ref={this.onMount}
+ onChange={this.onChange}
+ data-l10n-id={this.props.placeholderId}
+ // Set focus on error if the url field is valid or when the input is first rendered and is empty
+ // eslint-disable-next-line jsx-a11y/no-autofocus
+ autoFocus={this.props.autoFocusOnOpen}
+ disabled={this.props.loading}
+ />
+ {this.renderLoadingOrCloseButton()}
+ {validationError && (
+ <aside
+ className="error-tooltip"
+ data-l10n-id={this.props.errorMessageId}
+ />
+ )}
+ </div>
+ </label>
+ );
+ }
+}
+
+TopSiteFormInput.defaultProps = {
+ showClearButton: false,
+ value: "",
+ validationError: false,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx
new file mode 100644
index 0000000000..580809dd57
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSiteImpressionWrapper.jsx
@@ -0,0 +1,149 @@
+/* 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 } from "common/Actions.sys.mjs";
+import React from "react";
+
+const VISIBLE = "visible";
+const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+// Per analytical requirement, we set the minimal intersection ratio to
+// 0.5, and an impression is identified when the wrapped item has at least
+// 50% visibility.
+//
+// This constant is exported for unit test
+export const INTERSECTION_RATIO = 0.5;
+
+/**
+ * Impression wrapper for a TopSite tile.
+ *
+ * It makses use of the Intersection Observer API to detect the visibility,
+ * and relies on page visibility to ensure the impression is reported
+ * only when the component is visible on the page.
+ */
+export class TopSiteImpressionWrapper extends React.PureComponent {
+ _dispatchImpressionStats() {
+ const { actionType, tile } = this.props;
+ if (!actionType) {
+ return;
+ }
+
+ this.props.dispatch(
+ ac.OnlyToMain({
+ type: actionType,
+ data: {
+ type: "impression",
+ ...tile,
+ },
+ })
+ );
+ }
+
+ setImpressionObserverOrAddListener() {
+ const { props } = this;
+
+ if (!props.dispatch) {
+ return;
+ }
+
+ if (props.document.visibilityState === VISIBLE) {
+ this.setImpressionObserver();
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ this._onVisibilityChange = () => {
+ if (props.document.visibilityState === VISIBLE) {
+ this.setImpressionObserver();
+ props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ /**
+ * Set an impression observer for the wrapped component. It makes use of
+ * the Intersection Observer API to detect if the wrapped component is
+ * visible with a desired ratio, and only sends impression if that's the case.
+ *
+ * See more details about Intersection Observer API at:
+ * https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
+ */
+ setImpressionObserver() {
+ const { props } = this;
+
+ if (!props.tile) {
+ return;
+ }
+
+ this._handleIntersect = entries => {
+ if (
+ entries.some(
+ entry =>
+ entry.isIntersecting &&
+ entry.intersectionRatio >= INTERSECTION_RATIO
+ )
+ ) {
+ this._dispatchImpressionStats();
+ this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
+ }
+ };
+
+ const options = { threshold: INTERSECTION_RATIO };
+ this.impressionObserver = new props.IntersectionObserver(
+ this._handleIntersect,
+ options
+ );
+ this.impressionObserver.observe(this.refs.topsite_impression_wrapper);
+ }
+
+ componentDidMount() {
+ if (this.props.tile) {
+ this.setImpressionObserverOrAddListener();
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._handleIntersect && this.impressionObserver) {
+ this.impressionObserver.unobserve(this.refs.topsite_impression_wrapper);
+ }
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ render() {
+ return (
+ <div
+ ref={"topsite_impression_wrapper"}
+ className="topsite-impression-observer"
+ >
+ {this.props.children}
+ </div>
+ );
+ }
+}
+
+TopSiteImpressionWrapper.defaultProps = {
+ IntersectionObserver: global.IntersectionObserver,
+ document: global.document,
+ actionType: null,
+ tile: null,
+};
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSites.jsx b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
new file mode 100644
index 0000000000..fd1e3048a5
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSites.jsx
@@ -0,0 +1,213 @@
+/* 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.sys.mjs";
+import { MIN_RICH_FAVICON_SIZE, TOP_SITES_SOURCE } from "./TopSitesConstants";
+import { CollapsibleSection } from "content-src/components/CollapsibleSection/CollapsibleSection";
+import { ComponentPerfTimer } from "content-src/components/ComponentPerfTimer/ComponentPerfTimer";
+import { connect } from "react-redux";
+import { ModalOverlayWrapper } from "../../asrouter/components/ModalOverlay/ModalOverlay";
+import React from "react";
+import { SearchShortcutsForm } from "./SearchShortcutsForm";
+import { TOP_SITES_MAX_SITES_PER_ROW } from "common/Reducers.sys.mjs";
+import { TopSiteForm } from "./TopSiteForm";
+import { TopSiteList } from "./TopSite";
+
+function topSiteIconType(link) {
+ if (link.customScreenshotURL) {
+ return "custom_screenshot";
+ }
+ if (link.tippyTopIcon || link.faviconRef === "tippytop") {
+ return "tippytop";
+ }
+ if (link.faviconSize >= MIN_RICH_FAVICON_SIZE) {
+ return "rich_icon";
+ }
+ if (link.screenshot) {
+ return "screenshot";
+ }
+ return "no_image";
+}
+
+/**
+ * Iterates through TopSites and counts types of images.
+ * @param acc Accumulator for reducer.
+ * @param topsite Entry in TopSites.
+ */
+function countTopSitesIconsTypes(topSites) {
+ const countTopSitesTypes = (acc, link) => {
+ acc[topSiteIconType(link)]++;
+ return acc;
+ };
+
+ return topSites.reduce(countTopSitesTypes, {
+ custom_screenshot: 0,
+ screenshot: 0,
+ tippytop: 0,
+ rich_icon: 0,
+ no_image: 0,
+ });
+}
+
+export class _TopSites extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onEditFormClose = this.onEditFormClose.bind(this);
+ this.onSearchShortcutsFormClose =
+ this.onSearchShortcutsFormClose.bind(this);
+ }
+
+ /**
+ * Dispatch session statistics about the quality of TopSites icons and pinned count.
+ */
+ _dispatchTopSitesStats() {
+ const topSites = this._getVisibleTopSites().filter(
+ topSite => topSite !== null && topSite !== undefined
+ );
+ const topSitesIconsStats = countTopSitesIconsTypes(topSites);
+ const topSitesPinned = topSites.filter(site => !!site.isPinned).length;
+ const searchShortcuts = topSites.filter(
+ site => !!site.searchTopSite
+ ).length;
+ // Dispatch telemetry event with the count of TopSites images types.
+ this.props.dispatch(
+ ac.AlsoToMain({
+ type: at.SAVE_SESSION_PERF_DATA,
+ data: {
+ topsites_icon_stats: topSitesIconsStats,
+ topsites_pinned: topSitesPinned,
+ topsites_search_shortcuts: searchShortcuts,
+ },
+ })
+ );
+ }
+
+ /**
+ * Return the TopSites that are visible based on prefs and window width.
+ */
+ _getVisibleTopSites() {
+ // We hide 2 sites per row when not in the wide layout.
+ let sitesPerRow = TOP_SITES_MAX_SITES_PER_ROW;
+ // $break-point-widest = 1072px (from _variables.scss)
+ if (!global.matchMedia(`(min-width: 1072px)`).matches) {
+ sitesPerRow -= 2;
+ }
+ return this.props.TopSites.rows.slice(
+ 0,
+ this.props.TopSitesRows * sitesPerRow
+ );
+ }
+
+ componentDidUpdate() {
+ this._dispatchTopSitesStats();
+ }
+
+ componentDidMount() {
+ this._dispatchTopSitesStats();
+ }
+
+ onEditFormClose() {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "TOP_SITES_EDIT_CLOSE",
+ })
+ );
+ this.props.dispatch({ type: at.TOP_SITES_CANCEL_EDIT });
+ }
+
+ onSearchShortcutsFormClose() {
+ this.props.dispatch(
+ ac.UserEvent({
+ source: TOP_SITES_SOURCE,
+ event: "SEARCH_EDIT_CLOSE",
+ })
+ );
+ this.props.dispatch({ type: at.TOP_SITES_CLOSE_SEARCH_SHORTCUTS_MODAL });
+ }
+
+ render() {
+ const { props } = this;
+ const { editForm, showSearchShortcutsForm } = props.TopSites;
+ const extraMenuOptions = ["AddTopSite"];
+ const colors = props.Prefs.values["newNewtabExperience.colors"];
+
+ if (props.Prefs.values["improvesearch.topSiteSearchShortcuts"]) {
+ extraMenuOptions.push("AddSearchShortcut");
+ }
+
+ return (
+ <ComponentPerfTimer
+ id="topsites"
+ initialized={props.TopSites.initialized}
+ dispatch={props.dispatch}
+ >
+ <CollapsibleSection
+ className="top-sites"
+ id="topsites"
+ title={props.title || { id: "newtab-section-header-topsites" }}
+ hideTitle={true}
+ extraMenuOptions={extraMenuOptions}
+ showPrefName="feeds.topsites"
+ eventSource={TOP_SITES_SOURCE}
+ collapsed={false}
+ isFixed={props.isFixed}
+ isFirst={props.isFirst}
+ isLast={props.isLast}
+ dispatch={props.dispatch}
+ >
+ <TopSiteList
+ TopSites={props.TopSites}
+ TopSitesRows={props.TopSitesRows}
+ dispatch={props.dispatch}
+ topSiteIconType={topSiteIconType}
+ colors={colors}
+ />
+ <div className="edit-topsites-wrapper">
+ {editForm && (
+ <div className="edit-topsites">
+ <ModalOverlayWrapper
+ unstyled={true}
+ onClose={this.onEditFormClose}
+ innerClassName="modal"
+ >
+ <TopSiteForm
+ site={props.TopSites.rows[editForm.index]}
+ onClose={this.onEditFormClose}
+ dispatch={this.props.dispatch}
+ {...editForm}
+ />
+ </ModalOverlayWrapper>
+ </div>
+ )}
+ {showSearchShortcutsForm && (
+ <div className="edit-search-shortcuts">
+ <ModalOverlayWrapper
+ unstyled={true}
+ onClose={this.onSearchShortcutsFormClose}
+ innerClassName="modal"
+ >
+ <SearchShortcutsForm
+ TopSites={props.TopSites}
+ onClose={this.onSearchShortcutsFormClose}
+ dispatch={this.props.dispatch}
+ />
+ </ModalOverlayWrapper>
+ </div>
+ )}
+ </div>
+ </CollapsibleSection>
+ </ComponentPerfTimer>
+ );
+ }
+}
+
+export const TopSites = connect((state, props) => ({
+ TopSites: state.TopSites,
+ Prefs: state.Prefs,
+ TopSitesRows: state.Prefs.values.topSitesRows,
+}))(_TopSites);
diff --git a/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
new file mode 100644
index 0000000000..f488896238
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/TopSitesConstants.js
@@ -0,0 +1,39 @@
+/* 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/. */
+
+export const TOP_SITES_SOURCE = "TOP_SITES";
+export const TOP_SITES_CONTEXT_MENU_OPTIONS = [
+ "CheckPinTopSite",
+ "EditTopSite",
+ "Separator",
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "DeleteUrl",
+];
+export const TOP_SITES_SPOC_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "ShowPrivacyInfo",
+];
+export const TOP_SITES_SPONSORED_POSITION_CONTEXT_MENU_OPTIONS = [
+ "OpenInNewWindow",
+ "OpenInPrivateWindow",
+ "Separator",
+ "BlockUrl",
+ "AboutSponsored",
+];
+// the special top site for search shortcut experiment can only have the option to unpin (which removes) the topsite
+export const TOP_SITES_SEARCH_SHORTCUTS_CONTEXT_MENU_OPTIONS = [
+ "CheckPinTopSite",
+ "Separator",
+ "BlockUrl",
+];
+// minimum size necessary to show a rich icon instead of a screenshot
+export const MIN_RICH_FAVICON_SIZE = 96;
+// minimum size necessary to show any icon
+export const MIN_SMALL_FAVICON_SIZE = 16;
diff --git a/browser/components/newtab/content-src/components/TopSites/_TopSites.scss b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss
new file mode 100644
index 0000000000..b893b6b33e
--- /dev/null
+++ b/browser/components/newtab/content-src/components/TopSites/_TopSites.scss
@@ -0,0 +1,628 @@
+@use 'sass:math';
+
+$top-sites-size: $grid-unit-small;
+$top-sites-border-radius: 8px;
+$top-sites-icon-border-radius: 4px;
+$rich-icon-size: 96px;
+$default-icon-wrapper-size: 32px;
+$default-icon-size: 32px;
+$default-icon-offset: 6px;
+$half-base-gutter: math.div($base-gutter, 2);
+$hover-transition-duration: 150ms;
+$letter-fallback-color: $white;
+
+.top-sites-list {
+ list-style: none;
+ margin: 0 (-$half-base-gutter);
+ padding: 0;
+
+ a {
+ text-decoration: none;
+ }
+
+ &:not(.dnd-active) {
+ .top-site-outer:is(.active, :focus, :hover) {
+ background: var(--newtab-element-hover-color);
+ }
+ }
+
+ // Two columns
+ @media (max-width: $break-point-medium) {
+ > :nth-child(2n+1) {
+ @include context-menu-open-middle;
+ }
+
+ > :nth-child(2n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Four columns
+ @media (min-width: $break-point-medium) and (max-width: $break-point-large) {
+ :nth-child(4n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-medium) and (max-width: $break-point-medium + $card-width) {
+ :nth-child(4n+3) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Six columns
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + 2 * $card-width) {
+ :nth-child(6n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-large) and (max-width: $break-point-large + $card-width) {
+ :nth-child(6n+5) {
+ @include context-menu-open-left;
+ }
+ }
+
+ // Eight columns
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + 2 * $card-width) {
+ :nth-child(8n) {
+ @include context-menu-open-left;
+ }
+ }
+
+ @media (min-width: $break-point-widest) and (max-width: $break-point-widest + $card-width) {
+ :nth-child(8n+7) {
+ @include context-menu-open-left;
+ }
+ }
+
+ .hide-for-narrow {
+ display: none;
+ }
+
+ @media (min-width: $break-point-medium) {
+ .hide-for-narrow {
+ display: inline-block;
+ }
+ }
+
+ @media (min-width: $break-point-large) {
+ .hide-for-narrow {
+ display: none;
+ }
+ }
+
+ @media (min-width: $break-point-widest) {
+ .hide-for-narrow {
+ display: inline-block;
+ }
+ }
+}
+
+// container for drop zone
+.top-site-outer {
+ width: 120px;
+ padding: 20px $half-base-gutter 4px;
+ border-radius: 8px;
+ display: inline-block;
+
+ // container for context menu
+ .top-site-inner {
+ position: relative;
+
+ > a {
+ color: inherit;
+ display: block;
+ outline: none;
+ }
+ }
+
+ &:is(:hover) {
+ .context-menu-button {
+ opacity: 1;
+ }
+ }
+
+ .context-menu-button {
+ background-image: url('chrome://global/skin/icons/more.svg');
+ border: 0;
+ border-radius: 4px;
+ cursor: pointer;
+ fill: var(--newtab-text-primary-color);
+ -moz-context-properties: fill;
+ height: 20px;
+ width: 20px;
+ inset-inline-end: -9px;
+ opacity: 0;
+ position: absolute;
+ top: -20px;
+ transition: opacity 200ms;
+
+ &:is(:active, :focus) {
+ outline: 0;
+ opacity: 1;
+ background-color: var(--newtab-element-hover-color);
+ fill: var(--newtab-primary-action-background);
+ }
+ }
+
+ .tile {
+ border-radius: $top-sites-border-radius;
+ box-shadow: $shadow-card;
+ background-color: var(--newtab-background-color-secondary);
+ justify-content: center;
+ margin: 0 auto;
+ height: $top-sites-size;
+ width: $top-sites-size;
+ cursor: pointer;
+ position: relative;
+
+ // For letter fallback
+ align-items: center;
+ color: var(--newtab-text-secondary-color);
+ display: flex;
+ font-size: 32px;
+ font-weight: 200;
+ text-transform: uppercase;
+
+ .icon-wrapper {
+ border-radius: 4px;
+ width: 48px;
+ height: 48px;
+ overflow: hidden;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &.letter-fallback::before {
+ content: attr(data-fallback);
+ text-transform: uppercase;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 64px;
+ font-weight: 800;
+ transform: rotate(-10deg);
+ top: 6px;
+ position: relative;
+ color: $letter-fallback-color;
+ }
+ }
+ }
+
+ // Some common styles for all icons (rich and default) in top sites
+ .top-site-icon {
+ background-color: var(--newtab-background-color-secondary);
+ background-position: center center;
+ background-repeat: no-repeat;
+ border-radius: $top-sites-icon-border-radius;
+ position: absolute;
+ }
+
+ .rich-icon {
+ background-size: cover;
+ height: 100%;
+ inset-inline-start: 0;
+ top: 0;
+ width: 100%;
+ }
+
+ .default-icon,
+ .search-topsite {
+ background-size: $default-icon-size;
+ height: $default-icon-wrapper-size;
+ width: $default-icon-wrapper-size;
+
+ // for corner letter fallback
+ align-items: center;
+ display: flex;
+ font-size: 20px;
+ justify-content: center;
+
+ &[data-fallback]::before {
+ content: attr(data-fallback);
+ }
+ }
+
+ .search-topsite {
+ background-image: url('chrome://global/skin/icons/search-glass.svg');
+ background-size: 16px;
+ background-color: var(--newtab-primary-action-background);
+ border-radius: $default-icon-wrapper-size;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-element-text-color);
+ box-shadow: $shadow-card;
+ transition-duration: $hover-transition-duration;
+ transition-property: background-size, bottom, inset-inline-end, height, width;
+ height: 32px;
+ width: 32px;
+ bottom: -$default-icon-offset;
+ inset-inline-end: -$default-icon-offset;
+ }
+
+ &.placeholder {
+ .tile {
+ box-shadow: $inner-box-shadow;
+ }
+ }
+
+ .title {
+ color: var(--newtab-text-primary-color);
+ padding-top: 8px;
+ font: caption;
+ text-align: center;
+ position: relative;
+
+ .icon {
+ margin-inline-end: 2px;
+ fill: var(--newtab-text-primary-color);
+ }
+
+ span {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .sponsored-label {
+ color: var(--newtab-text-secondary-color);
+ font-size: 0.9em;
+ }
+
+ &:not(.sponsored) .sponsored-label {
+ visibility: hidden;
+ }
+ }
+
+ // We want all search shortcuts to have a white background in case they have transparency.
+ &.search-shortcut {
+ .rich-icon {
+ background-color: $white;
+ }
+ }
+
+ .edit-button {
+ background-image: url('chrome://global/skin/icons/edit.svg');
+ }
+
+ &.dragged {
+ .tile {
+ *,
+ &::before {
+ display: none;
+ }
+ }
+
+ .title {
+ visibility: hidden;
+ }
+ }
+}
+
+.edit-topsites-wrapper {
+ .top-site-inner > .top-site-button > .tile {
+ border: 1px solid var(--newtab-border-color);
+ }
+
+ .modal {
+ box-shadow: $shadow-secondary;
+ left: 0;
+ margin: 0 auto;
+ max-height: calc(100% - 40px);
+ position: fixed;
+ right: 0;
+ top: 40px;
+ width: $wrapper-default-width;
+
+ @media (min-width: $break-point-medium) {
+ width: $wrapper-max-width-medium;
+ }
+
+ @media (min-width: $break-point-large) {
+ width: $wrapper-max-width-large;
+ }
+ }
+}
+
+.topsite-form {
+ $form-width: 300px;
+ $form-spacing: 32px;
+
+ .section-title {
+ font-size: 16px;
+ margin: 0 0 16px;
+ }
+
+ .form-input-container {
+ max-width: $form-width + 3 * $form-spacing + $rich-icon-size;
+ margin: 0 auto;
+ padding: $form-spacing;
+
+ .top-site-outer {
+ pointer-events: none;
+ }
+ }
+
+ .search-shortcuts-container {
+ max-width: 700px;
+ margin: 0 auto;
+ padding: $form-spacing;
+
+ > div {
+ margin-inline-end: -39px;
+ }
+
+ .top-site-outer {
+ margin-inline-start: 0;
+ margin-inline-end: 39px;
+ }
+ }
+
+ .top-site-outer {
+ padding: 0;
+ margin: 24px 0 0;
+ margin-inline-start: $form-spacing;
+ }
+
+ .fields-and-preview {
+ display: flex;
+ }
+
+ label {
+ font-size: $section-title-font-size;
+ }
+
+ .form-wrapper {
+ width: 100%;
+
+ .field {
+ position: relative;
+
+ .icon-clear-input {
+ position: absolute;
+ transform: translateY(-50%);
+ top: 50%;
+ inset-inline-end: 8px;
+ }
+ }
+
+ .url {
+ input:dir(ltr) {
+ padding-right: 32px;
+ }
+
+ input:dir(rtl) {
+ padding-left: 32px;
+
+ &:not(:placeholder-shown) {
+ direction: ltr;
+ text-align: right;
+ }
+ }
+ }
+
+ .enable-custom-image-input {
+ display: inline-block;
+ font-size: 13px;
+ margin-top: 4px;
+ cursor: pointer;
+ }
+
+ .custom-image-input-container {
+ margin-top: 4px;
+
+ .loading-container {
+ width: 16px;
+ height: 16px;
+ overflow: hidden;
+ position: absolute;
+ transform: translateY(-50%);
+ top: 50%;
+ inset-inline-end: 8px;
+ }
+
+ // This animation is derived from Firefox's tab loading animation
+ // See https://searchfox.org/mozilla-central/rev/b29daa46443b30612415c35be0a3c9c13b9dc5f6/browser/themes/shared/tabs.inc.css#208-216
+ .loading-animation {
+ @keyframes tab-throbber-animation {
+ 100% { transform: translateX(-960px); }
+ }
+
+ @keyframes tab-throbber-animation-rtl {
+ 100% { transform: translateX(960px); }
+ }
+
+ width: 960px;
+ height: 16px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ background-image: url('chrome://browser/skin/tabbrowser/loading.svg');
+ animation: tab-throbber-animation 1.05s steps(60) infinite;
+
+ &:dir(rtl) {
+ animation-name: tab-throbber-animation-rtl;
+ }
+ }
+ }
+
+ input {
+ &[type='text'] {
+ background-color: var(--newtab-background-color-secondary);
+ border: $input-border;
+ margin: 8px 0;
+ padding: 0 8px;
+ height: 32px;
+ width: 100%;
+ font-size: 15px;
+
+ &[disabled] {
+ border: $input-border;
+ box-shadow: none;
+ opacity: 0.4;
+ }
+ }
+ }
+
+ .invalid {
+ input {
+ &[type='text'] {
+ border: $input-error-border;
+ box-shadow: $input-error-boxshadow;
+ }
+ }
+ }
+
+ .error-tooltip {
+ animation: fade-up-tt 450ms;
+ background: var(--newtab-status-error);
+ border-radius: 2px;
+ color: $white;
+ inset-inline-start: 3px;
+ padding: 5px 12px;
+ position: absolute;
+ top: 44px;
+ z-index: 1;
+
+ // tooltip caret
+ &::before {
+ background: var(--newtab-status-error);
+ bottom: -8px;
+ content: '.';
+ height: 16px;
+ inset-inline-start: 12px;
+ position: absolute;
+ text-indent: -999px;
+ top: -7px;
+ transform: rotate(45deg);
+ white-space: nowrap;
+ width: 16px;
+ z-index: -1;
+ }
+ }
+ }
+
+ .actions {
+ justify-content: flex-end;
+
+ button {
+ margin-inline-start: 10px;
+ margin-inline-end: 0;
+ }
+ }
+
+ @media (max-width: $break-point-medium) {
+ .fields-and-preview {
+ flex-direction: column;
+
+ .top-site-outer {
+ margin-inline-start: 0;
+ }
+ }
+ }
+
+ // prevent text selection of keyword label when clicking to select
+ .title {
+ user-select: none;
+ }
+
+ // CSS styled checkbox
+ [type='checkbox']:not(:checked),
+ [type='checkbox']:checked {
+ inset-inline-start: -9999px;
+ position: absolute;
+ }
+
+ [type='checkbox']:not(:checked) + label,
+ [type='checkbox']:checked + label {
+ cursor: pointer;
+ display: block;
+ position: relative;
+ }
+
+ $checkbox-offset: -8px;
+
+ [type='checkbox']:not(:checked) + label::before,
+ [type='checkbox']:checked + label::before {
+ background: var(--newtab-background-color);
+ border: $input-border;
+ border-radius: $border-radius;
+ content: '';
+ height: 21px;
+ left: $checkbox-offset;
+ position: absolute;
+ top: $checkbox-offset;
+ width: 21px;
+ z-index: 1;
+
+ [dir='rtl'] & {
+ left: auto;
+ right: $checkbox-offset;
+ }
+ }
+
+ // checkmark
+ [type='checkbox']:not(:checked) + label::after,
+ [type='checkbox']:checked + label::after {
+ background: url('chrome://global/skin/icons/check.svg') no-repeat center center;
+ content: '';
+ height: 21px;
+ left: $checkbox-offset;
+ position: absolute;
+ top: $checkbox-offset;
+ width: 21px;
+ -moz-context-properties: fill;
+ fill: var(--newtab-primary-action-background);
+ z-index: 2;
+
+ [dir='rtl'] & {
+ left: auto;
+ right: $checkbox-offset;
+ }
+ }
+
+ // when selected, highlight the tile
+ [type='checkbox']:checked + label {
+ .tile {
+ box-shadow: $shadow-focus;
+ }
+ }
+
+ // checkmark changes
+ [type='checkbox']:not(:checked) + label::after {
+ opacity: 0;
+ }
+
+ [type='checkbox']:checked + label::after {
+ opacity: 1;
+ }
+
+ // accessibility
+ [type='checkbox']:checked:focus + label::before,
+ [type='checkbox']:not(:checked):focus + label::before {
+ border: 1px dotted var(--newtab-primary-action-background);
+ }
+}
+
+// used for tooltips below form element
+@keyframes fade-up-tt {
+ 0% {
+ opacity: 0;
+ transform: translateY(15px);
+ }
+
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+// used for TopSites impression wrapper
+.topsite-impression-observer {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}