commit bd3476c70053bbcf4b41f84b2630c46f1a3c0532
parent 10945aef958c54af21b95adbbf0cc6875299e801
Author: mimi <nsauermann@mozilla.com>
Date: Thu, 30 Oct 2025 20:03:55 +0000
Bug 1996319 - [TOS] Enable ContentTiles to render below LinkParagraph and buttons and update styles r=omc-reviewers,emcminn
Adds new property to describe and determine action buttons position: `content.action_buttons_position` with the following valid positions (`after_subtitle`, `after_supporting_content` and `end` (default) in order to render action buttons after legal paragraph but before content tiles.
For backwards compatibility, legacy property `action_buttons_above_content` maps to `after_subtitle`.
And normalizes tiles into a single shape (tiles: { tiles_items: [], container: {style?: {}, header?: {} }) and keeps backwards compatibility with legacy properties (contentTilesContainer and tiles_header)
Differential Revision: https://phabricator.services.mozilla.com/D270016
Diffstat:
6 files changed, 570 insertions(+), 79 deletions(-)
diff --git a/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx b/browser/components/aboutwelcome/content-src/components/ContentTiles.jsx
@@ -59,11 +59,18 @@ export const ContentTiles = props => {
return null;
}
+ const { tile_items, container } =
+ AboutWelcomeUtils.normalizeContentTiles(content);
+
+ if (!tile_items.length) {
+ return null;
+ }
+
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
// Run once when ContentTiles mounts to prefill activeMultiSelect
if (!props.activeMultiSelect) {
- const tilesArray = Array.isArray(tiles) ? tiles : [tiles];
+ const tilesArray = Array.isArray(tile_items) ? tile_items : [tile_items];
tilesArray.forEach((tile, index) => {
if (tile.type !== "multiselect" || !tile.data) {
@@ -84,7 +91,7 @@ export const ContentTiles = props => {
}
});
}
- }, [tiles]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [tile_items]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
/**
@@ -334,24 +341,36 @@ export const ContentTiles = props => {
};
const renderContentTiles = () => {
- if (Array.isArray(tiles)) {
- return (
- <div
- id="content-tiles-container"
- style={AboutWelcomeUtils.getValidStyle(
- content?.contentTilesContainer?.style,
- CONTAINER_STYLES
- )}
- >
- {tiles.map((tile, index) => renderContentTile(tile, index))}
- </div>
- );
+ const hasHeader = !!container?.header;
+ const hasContainerStyle = !!Object.keys(container?.style || {}).length;
+
+ // Legacy rule: tiles as a single object renders without a container.
+ // Arrays (even length 1) render inside a container.
+ // Normalize helper will detect original input shape (object vs array) before normalizing to preserve intent.
+ const isArrayInput = Array.isArray(content.tiles);
+ if (
+ !isArrayInput &&
+ tile_items.length === 1 &&
+ !hasHeader &&
+ !hasContainerStyle
+ ) {
+ return renderContentTile(tile_items[0], 0);
}
- // If tiles is not an array render the tile alone without a container
- return renderContentTile(tiles, 0);
+
+ return (
+ <div
+ id="content-tiles-container"
+ style={AboutWelcomeUtils.getValidStyle(
+ container?.style,
+ CONTAINER_STYLES
+ )}
+ >
+ {tile_items.map((tile, index) => renderContentTile(tile, index))}
+ </div>
+ );
};
- if (content.tiles_header) {
+ if (container?.header) {
return (
<React.Fragment>
<button
@@ -360,7 +379,7 @@ export const ContentTiles = props => {
aria-expanded={tilesHeaderExpanded}
aria-controls={`content-tiles-container`}
>
- <Localized text={content.tiles_header.title}>
+ <Localized text={container.header?.title}>
<span className="header-title" />
</Localized>
<div className="arrow-icon"></div>
@@ -369,5 +388,5 @@ export const ContentTiles = props => {
</React.Fragment>
);
}
- return renderContentTiles(tiles);
+ return renderContentTiles(tile_items);
};
diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx
@@ -552,6 +552,7 @@ export class ProtonScreen extends React.PureComponent {
</div>
);
}
+
getCombinedInnerStyles(content, isWideScreen) {
const CONFIGURABLE_STYLES = [
"overflow",
@@ -580,6 +581,39 @@ export class ProtonScreen extends React.PureComponent {
};
}
+ getActionButtonsPosition(content) {
+ const VALID_POSITIONS = [
+ "after_subtitle",
+ "after_supporting_content",
+ "end",
+ ];
+
+ if (VALID_POSITIONS.includes(content.action_buttons_position)) {
+ return content.action_buttons_position;
+ }
+ // Legacy mapping
+ if (content.action_buttons_above_content) {
+ return "after_subtitle";
+ }
+ // Default
+ return "end";
+ }
+
+ renderActionButtons(position, content) {
+ return this.getActionButtonsPosition(content) === position ? (
+ <ProtonScreenActionButtons
+ content={content}
+ isRtamo={this.props.isRtamo}
+ installedAddons={this.props.installedAddons}
+ addonId={this.props.addonId}
+ addonName={this.props.addonName}
+ addonType={this.props.addonType}
+ handleAction={this.props.handleAction}
+ activeMultiSelect={this.props.activeMultiSelect}
+ />
+ ) : null;
+ }
+
// eslint-disable-next-line complexity
render() {
const {
@@ -616,7 +650,6 @@ export class ProtonScreen extends React.PureComponent {
const isEmbeddedMigration = content.tiles?.type === "migration-wizard";
const isSystemPromptStyleSpotlight =
content.isSystemPromptStyleSpotlight === true;
-
const combinedStyles = this.getCombinedInnerStyles(content, isWideScreen);
return (
@@ -695,7 +728,6 @@ export class ProtonScreen extends React.PureComponent {
{content.logo && !content.fullscreen
? this.renderPicture(content.logo)
: null}
-
{isRtamo && !content.fullscreen
? this.renderRTAMOIcon(
addonType,
@@ -703,7 +735,6 @@ export class ProtonScreen extends React.PureComponent {
this.props.addonIconURL
)
: null}
-
<div className="main-content-inner" style={combinedStyles}>
{content.logo && content.fullscreen
? this.renderPicture(content.logo)
@@ -738,18 +769,7 @@ export class ProtonScreen extends React.PureComponent {
/>
</Localized>
) : null}
- {content.action_buttons_above_content && (
- <ProtonScreenActionButtons
- content={content}
- isRtamo={this.props.isRtamo}
- installedAddons={this.props.installedAddons}
- addonId={this.props.addonId}
- addonName={this.props.addonName}
- addonType={this.props.addonType}
- handleAction={this.props.handleAction}
- activeMultiSelect={this.props.activeMultiSelect}
- />
- )}
+ {this.renderActionButtons("after_subtitle", content)}
{content.cta_paragraph ? (
<CTAParagraph
content={content.cta_paragraph}
@@ -764,26 +784,16 @@ export class ProtonScreen extends React.PureComponent {
handleAction={this.props.handleAction}
/>
) : null}
- <ContentTiles {...this.props} />
{this.renderLanguageSwitcher()}
{content.above_button_content
? this.renderOrderedContent(content.above_button_content)
: null}
+ {this.renderActionButtons("after_supporting_content", content)}
+ <ContentTiles {...this.props} />
{!hideStepsIndicator && aboveButtonStepsIndicator
? this.renderStepsIndicator()
: null}
- {!content.action_buttons_above_content && (
- <ProtonScreenActionButtons
- content={content}
- isRtamo={this.props.isRtamo}
- installedAddons={this.props.installedAddons}
- addonId={this.props.addonId}
- addonName={this.props.addonName}
- addonType={this.props.addonType}
- handleAction={this.props.handleAction}
- activeMultiSelect={this.props.activeMultiSelect}
- />
- )}
+ {this.renderActionButtons("end", content)}
{
/* Fullscreen dot-style step indicator should sit inside the
main inner content to share its padding, which will be
diff --git a/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs b/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs
@@ -100,4 +100,47 @@ export const AboutWelcomeUtils = {
true
);
},
+
+ /**
+ * Normalize content.tiles into a single shape:
+ * tiles: { tile_items: Array<Tile> | Tile, container?: { style?: Object, header?: Object } }
+ *
+ * Supports legacy tiles array and single tile object and consumes
+ * legacy container `content.contentTilesContainer.style` and
+ * legacy header `content.tiles_header` properties.
+ */
+ normalizeContentTiles(content) {
+ const { tiles } = content;
+ const legacyContainer = content?.contentTilesContainer;
+ const legacyHeader = content?.tiles_header;
+
+ // Prefer tiles.container styles, fallback to legacy style, default {}
+ const style = tiles?.container?.style ?? legacyContainer?.style ?? {};
+
+ // Prefer tiles.container.header, fall back to legacy header
+ const header = tiles?.container?.header ?? legacyHeader;
+
+ let items;
+ // New shape
+ if (tiles?.tile_items !== undefined) {
+ items = Array.isArray(tiles.tile_items)
+ ? tiles.tile_items
+ : [tiles.tile_items];
+ }
+ // Legacy tiles array
+ else if (Array.isArray(tiles)) {
+ items = tiles;
+ }
+ // Legacy tiles object
+ else if (tiles && typeof tiles === "object" && tiles.type) {
+ items = [tiles];
+ } else {
+ items = [];
+ }
+
+ // Omit header when absent
+ const container = header ? { style, header } : { style };
+
+ return { tile_items: items, container };
+ },
};
diff --git a/browser/components/aboutwelcome/content/aboutwelcome.bundle.js b/browser/components/aboutwelcome/content/aboutwelcome.bundle.js
@@ -129,6 +129,49 @@ const AboutWelcomeUtils = {
true
);
},
+
+ /**
+ * Normalize content.tiles into a single shape:
+ * tiles: { tile_items: Array<Tile> | Tile, container?: { style?: Object, header?: Object } }
+ *
+ * Supports legacy tiles array and single tile object and consumes
+ * legacy container `content.contentTilesContainer.style` and
+ * legacy header `content.tiles_header` properties.
+ */
+ normalizeContentTiles(content) {
+ const { tiles } = content;
+ const legacyContainer = content?.contentTilesContainer;
+ const legacyHeader = content?.tiles_header;
+
+ // Prefer tiles.container styles, fallback to legacy style, default {}
+ const style = tiles?.container?.style ?? legacyContainer?.style ?? {};
+
+ // Prefer tiles.container.header, fall back to legacy header
+ const header = tiles?.container?.header ?? legacyHeader;
+
+ let items;
+ // New shape
+ if (tiles?.tile_items !== undefined) {
+ items = Array.isArray(tiles.tile_items)
+ ? tiles.tile_items
+ : [tiles.tile_items];
+ }
+ // Legacy tiles array
+ else if (Array.isArray(tiles)) {
+ items = tiles;
+ }
+ // Legacy tiles object
+ else if (tiles && typeof tiles === "object" && tiles.type) {
+ items = [tiles];
+ } else {
+ items = [];
+ }
+
+ // Omit header when absent
+ const container = header ? { style, header } : { style };
+
+ return { tile_items: items, container };
+ },
};
@@ -1516,6 +1559,30 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom
justifyContent: content.split_content_justify_content
};
}
+ getActionButtonsPosition(content) {
+ const VALID_POSITIONS = ["after_subtitle", "after_supporting_content", "end"];
+ if (VALID_POSITIONS.includes(content.action_buttons_position)) {
+ return content.action_buttons_position;
+ }
+ // Legacy mapping
+ if (content.action_buttons_above_content) {
+ return "after_subtitle";
+ }
+ // Default
+ return "end";
+ }
+ renderActionButtons(position, content) {
+ return this.getActionButtonsPosition(content) === position ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, {
+ content: content,
+ isRtamo: this.props.isRtamo,
+ installedAddons: this.props.installedAddons,
+ addonId: this.props.addonId,
+ addonName: this.props.addonName,
+ addonType: this.props.addonType,
+ handleAction: this.props.handleAction,
+ activeMultiSelect: this.props.activeMultiSelect
+ }) : null;
+ }
// eslint-disable-next-line complexity
render() {
@@ -1588,31 +1655,13 @@ class ProtonScreen extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureCom
}),
"aria-flowto": this.props.messageId?.includes("FEATURE_TOUR") ? "steps" : "",
id: "mainContentSubheader"
- })) : null, content.action_buttons_above_content && /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, {
- content: content,
- isRtamo: this.props.isRtamo,
- installedAddons: this.props.installedAddons,
- addonId: this.props.addonId,
- addonName: this.props.addonName,
- addonType: this.props.addonType,
- handleAction: this.props.handleAction,
- activeMultiSelect: this.props.activeMultiSelect
- }), content.cta_paragraph ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_CTAParagraph__WEBPACK_IMPORTED_MODULE_5__.CTAParagraph, {
+ })) : null, this.renderActionButtons("after_subtitle", content), content.cta_paragraph ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_CTAParagraph__WEBPACK_IMPORTED_MODULE_5__.CTAParagraph, {
content: content.cta_paragraph,
handleAction: this.props.handleAction
}) : null) : null, content.video_container ? /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_OnboardingVideo__WEBPACK_IMPORTED_MODULE_7__.OnboardingVideo, {
content: content.video_container,
handleAction: this.props.handleAction
- }) : null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_ContentTiles__WEBPACK_IMPORTED_MODULE_10__.ContentTiles, this.props), this.renderLanguageSwitcher(), content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null, !hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null, !content.action_buttons_above_content && /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(ProtonScreenActionButtons, {
- content: content,
- isRtamo: this.props.isRtamo,
- installedAddons: this.props.installedAddons,
- addonId: this.props.addonId,
- addonName: this.props.addonName,
- addonType: this.props.addonType,
- handleAction: this.props.handleAction,
- activeMultiSelect: this.props.activeMultiSelect
- }),
+ }) : null, this.renderLanguageSwitcher(), content.above_button_content ? this.renderOrderedContent(content.above_button_content) : null, this.renderActionButtons("after_supporting_content", content), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_ContentTiles__WEBPACK_IMPORTED_MODULE_10__.ContentTiles, this.props), !hideStepsIndicator && aboveButtonStepsIndicator ? this.renderStepsIndicator() : null, this.renderActionButtons("end", content),
/* Fullscreen dot-style step indicator should sit inside the
main inner content to share its padding, which will be
configurable with Bug 1956042 */
@@ -2337,12 +2386,19 @@ const ContentTiles = props => {
if (!tiles) {
return null;
}
+ const {
+ tile_items,
+ container
+ } = _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.normalizeContentTiles(content);
+ if (!tile_items.length) {
+ return null;
+ }
// eslint-disable-next-line react-hooks/rules-of-hooks
(0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => {
// Run once when ContentTiles mounts to prefill activeMultiSelect
if (!props.activeMultiSelect) {
- const tilesArray = Array.isArray(tiles) ? tiles : [tiles];
+ const tilesArray = Array.isArray(tile_items) ? tile_items : [tile_items];
tilesArray.forEach((tile, index) => {
if (tile.type !== "multiselect" || !tile.data) {
return;
@@ -2362,7 +2418,7 @@ const ContentTiles = props => {
}
});
}
- }, [tiles]); // eslint-disable-line react-hooks/exhaustive-deps
+ }, [tile_items]); // eslint-disable-line react-hooks/exhaustive-deps
(0,react__WEBPACK_IMPORTED_MODULE_0__.useEffect)(() => {
/**
@@ -2555,30 +2611,36 @@ const ContentTiles = props => {
})) : null);
};
const renderContentTiles = () => {
- if (Array.isArray(tiles)) {
- return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
- id: "content-tiles-container",
- style: _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.getValidStyle(content?.contentTilesContainer?.style, CONTAINER_STYLES)
- }, tiles.map((tile, index) => renderContentTile(tile, index)));
+ const hasHeader = !!container?.header;
+ const hasContainerStyle = !!Object.keys(container?.style || {}).length;
+
+ // Legacy rule: tiles as a single object renders without a container.
+ // Arrays (even length 1) render inside a container.
+ // Normalize helper will detect original input shape (object vs array) before normalizing to preserve intent.
+ const isArrayInput = Array.isArray(content.tiles);
+ if (!isArrayInput && tile_items.length === 1 && !hasHeader && !hasContainerStyle) {
+ return renderContentTile(tile_items[0], 0);
}
- // If tiles is not an array render the tile alone without a container
- return renderContentTile(tiles, 0);
+ return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
+ id: "content-tiles-container",
+ style: _lib_aboutwelcome_utils_mjs__WEBPACK_IMPORTED_MODULE_11__.AboutWelcomeUtils.getValidStyle(container?.style, CONTAINER_STYLES)
+ }, tile_items.map((tile, index) => renderContentTile(tile, index)));
};
- if (content.tiles_header) {
+ if (container?.header) {
return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement((react__WEBPACK_IMPORTED_MODULE_0___default().Fragment), null, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("button", {
className: "content-tiles-header secondary",
onClick: toggleTiles,
"aria-expanded": tilesHeaderExpanded,
"aria-controls": `content-tiles-container`
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(_MSLocalized__WEBPACK_IMPORTED_MODULE_1__.Localized, {
- text: content.tiles_header.title
+ text: container.header?.title
}, /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("span", {
className: "header-title"
})), /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement("div", {
className: "arrow-icon"
})), tilesHeaderExpanded && renderContentTiles());
}
- return renderContentTiles(tiles);
+ return renderContentTiles(tile_items);
};
/***/ }),
diff --git a/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx b/browser/components/aboutwelcome/tests/unit/ContentTiles.test.jsx
@@ -1033,4 +1033,256 @@ describe("ContentTiles component", () => {
);
mountedWrapper.unmount();
});
+
+ it("renders a single tile without container when there is no header or container style", () => {
+ const SINGLE_TILE_NO_CONTAINER = {
+ tiles: {
+ tile_items: {
+ type: "mobile_downloads",
+ data: { email: { link_text: "Email!" } },
+ },
+ container: {},
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={SINGLE_TILE_NO_CONTAINER}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ assert.isFalse(
+ mounted.find("#content-tiles-container").exists(),
+ "should not render container wrapper"
+ );
+ assert.equal(mounted.find(".content-tile").length, 1);
+
+ mounted.unmount();
+ });
+
+ it("renders container when single tile has container.style", () => {
+ const SINGLE_TILE_WITH_STYLE = {
+ tiles: {
+ container: { style: { marginBlock: "40px" } },
+ tile_items: {
+ type: "mobile_downloads",
+ data: { email: { link_text: "Email!!" } },
+ },
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={SINGLE_TILE_WITH_STYLE}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ const container = mounted.find("#content-tiles-container");
+ assert.isTrue(container.exists(), "container should be rendered");
+
+ const el = container.getDOMNode();
+ assert.match(
+ el.style.cssText,
+ "margin-block: 40px",
+ "container style applied"
+ );
+
+ mounted.unmount();
+ });
+
+ it("renders container when single tile has header", () => {
+ const SINGLE_TILE_WITH_HEADER = {
+ tiles: {
+ container: {
+ header: { title: "Toggle tiles header" },
+ },
+ tile_items: {
+ type: "mobile_downloads",
+ data: { email: { link_text: "Email!!" } },
+ },
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={SINGLE_TILE_WITH_HEADER}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ const headerBtn = mounted.find(".content-tiles-header");
+ assert.isTrue(headerBtn.exists(), "header button should render");
+
+ assert.isFalse(
+ mounted.find("#content-tiles-container").exists(),
+ "container should be hidden initially"
+ );
+
+ headerBtn.simulate("click");
+ assert.isTrue(
+ mounted.find("#content-tiles-container").exists(),
+ "container should render after toggle"
+ );
+
+ mounted.unmount();
+ });
+
+ it("supports legacy content.tiles_header as container header", () => {
+ const LEGACY_HEADER_SINGLE_TILE = {
+ tiles_header: { title: "Legacy tile header" },
+ tiles: {
+ type: "multiselect",
+ header: {
+ title: "Multiselect header",
+ },
+ data: [{ id: "option1", defaultValue: true }],
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={LEGACY_HEADER_SINGLE_TILE}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ const headerBtn = mounted.find(".content-tiles-header");
+ assert.isTrue(headerBtn.exists(), "legacy header button should render");
+
+ assert.isFalse(
+ mounted.find("#content-tiles-container").exists(),
+ "container hidden initially"
+ );
+
+ headerBtn.simulate("click");
+ assert.isTrue(
+ mounted.find("#content-tiles-container").exists(),
+ "container visible after toggle"
+ );
+
+ mounted.unmount();
+ });
+
+ it("supports legacy content.contentTilesContainer.style as container style", () => {
+ const LEGACY_CONTAINER_STYLE = {
+ contentTilesContainer: { style: { marginBlock: "6px" } },
+ tiles: [
+ {
+ type: "multiselect",
+ header: {
+ title: "Multiselect Header",
+ },
+ data: [{ id: "option1", defaultValue: true }],
+ },
+ ],
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={LEGACY_CONTAINER_STYLE}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ setScreenMultiSelects={sandbox.stub()}
+ />
+ );
+
+ const container = mounted.find("#content-tiles-container");
+ assert.isTrue(container.exists(), "container should be rendered");
+
+ const el = container.getDOMNode();
+ assert.match(
+ el.style.cssText,
+ "margin-block: 6px",
+ "legacy container style applied"
+ );
+
+ mounted.unmount();
+ });
+
+ it("renders header toggle when only header.subtitle is provided", () => {
+ const HEADER_WITH_SUBTITLE_ONLY = {
+ tiles: {
+ container: {
+ header: { subtitle: "Only a subtitle" },
+ },
+ tile_items: [
+ {
+ type: "mobile_downloads",
+ data: { email: { link_text: "Email yourself a link" } },
+ },
+ ],
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={HEADER_WITH_SUBTITLE_ONLY}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ const headerBtn = mounted.find(".content-tiles-header");
+ assert.isTrue(
+ headerBtn.exists(),
+ "header toggle renders with subtitle only"
+ );
+
+ headerBtn.simulate("click");
+ assert.isTrue(
+ mounted.find("#content-tiles-container").exists(),
+ "container renders after toggle"
+ );
+
+ mounted.unmount();
+ });
+
+ it("multiple tiles render inside container and count matches", () => {
+ const NEW_SHAPE_MULTIPLE = {
+ tiles: {
+ tile_items: [
+ {
+ type: "mobile_downloads",
+ data: { email: { link_text: "Email" } },
+ },
+ {
+ type: "multiselect",
+ header: {
+ title: "Multiselect header",
+ },
+ data: [{ id: "option1", defaultValue: true }],
+ },
+ ],
+ },
+ };
+
+ const mounted = mount(
+ <ContentTiles
+ content={NEW_SHAPE_MULTIPLE}
+ handleAction={() => {}}
+ activeMultiSelect={null}
+ setActiveMultiSelect={setActiveMultiSelect}
+ />
+ );
+
+ assert.isTrue(
+ mounted.find("#content-tiles-container").exists(),
+ "container present"
+ );
+ assert.equal(mounted.find(".content-tile").length, 2, "renders both tiles");
+
+ mounted.unmount();
+ });
});
diff --git a/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx b/browser/components/aboutwelcome/tests/unit/MultiStageAWProton.test.jsx
@@ -825,6 +825,111 @@ describe("MultiStageAboutWelcomeProton module", () => {
"Second child is ProtonScreenActionButtons"
);
});
+
+ it("should render action buttons after tiles by default when no position is configured", async () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "test title",
+ position: "center",
+ primary_button: { label: "Confirm and continue" },
+ tiles_header: { title: "Title" },
+ tiles: {
+ type: "multiselect",
+ data: [
+ { id: "checkbox-1", label: "Option 1" },
+ { id: "checkbox-2", label: "Option 2" },
+ ],
+ },
+ },
+ };
+
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+
+ const mainInner = wrapper.find(".main-content-inner");
+ const lastChild = mainInner.children().last();
+ assert.strictEqual(
+ lastChild.type(),
+ ProtonScreenActionButtons,
+ "Last child is ProtonScreenActionButtons"
+ );
+ });
+
+ it("should render action buttons after subtitle when configured", async () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "Test title",
+ subtitle: "Test subtitle",
+ position: "center",
+ action_buttons_position: "after_subtitle",
+ primary_button: { label: "Get started" },
+ },
+ };
+
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists(), "Screen renders");
+
+ // Find the welcome text container
+ const welcomeTextEl = wrapper.find(".welcome-text");
+ assert.isTrue(welcomeTextEl.exists(), "Welcome text exists");
+
+ const subtitleEl = welcomeTextEl.find("h2");
+ assert.ok(subtitleEl.exists(), "Subtitle exists");
+ const nextEl = subtitleEl.getDOMNode().nextElementSibling;
+ assert.isTrue(
+ nextEl.classList.contains("action-buttons"),
+ "Next element is action-buttons"
+ );
+ });
+
+ it("should render action buttons after supporting content but before tiles when configured", async () => {
+ const SCREEN_PROPS = {
+ content: {
+ title: "Welcome to Firefox",
+ position: "center",
+ action_buttons_position: "after_supporting_content",
+ above_button_content: [
+ {
+ type: "text",
+ text: { string_id: "tou-existing-user-spotlight-body" },
+ font_styles: "legal",
+ link_keys: ["terms-of-use", "privacy-notice", "learn-more"],
+ },
+ ],
+ primary_button: { label: "Confirm and continue" },
+ tiles_header: {
+ title: {
+ string_id: "preonboarding-manage-data-header-button-title",
+ },
+ },
+ tiles: {
+ type: "multiselect",
+ data: [
+ { id: "checkbox-1", label: "Interaction data" },
+ { id: "checkbox-2", label: "Crash data" },
+ ],
+ },
+ },
+ };
+
+ const wrapper = mount(<MultiStageProtonScreen {...SCREEN_PROPS} />);
+ assert.ok(wrapper.exists());
+
+ const legalParagraphEl = wrapper.find(".legal-paragraph");
+ assert.equal(legalParagraphEl.exists(), true, "Legal paragraph renders");
+
+ const nextEl = legalParagraphEl.getDOMNode().nextElementSibling;
+ assert.isTrue(
+ nextEl.classList.contains("action-buttons"),
+ "Next element after legal paragraph should be action buttons"
+ );
+
+ const afterButtonsEl = nextEl.nextElementSibling;
+ assert.isTrue(
+ afterButtonsEl.classList.contains("content-tiles-header"),
+ "Next element after action buttons should be content-tiles-header"
+ );
+ });
});
describe("AboutWelcomeDefaults for proton", () => {