summaryrefslogtreecommitdiffstats
path: root/browser/components/storybook/.storybook
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/storybook/.storybook')
-rw-r--r--browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs114
-rw-r--r--browser/components/storybook/.storybook/addon-component-status/constants.mjs7
-rw-r--r--browser/components/storybook/.storybook/addon-component-status/index.js23
-rw-r--r--browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs19
-rw-r--r--browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs12
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/FluentPanel.mjs79
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/PseudoLocalizationButton.mjs55
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/constants.mjs32
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/index.js23
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/preset/manager.mjs34
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/preset/preview.mjs27
-rw-r--r--browser/components/storybook/.storybook/addon-pseudo-localization/withPseudoLocalization.mjs89
-rw-r--r--browser/components/storybook/.storybook/chrome-uri-loader.js98
-rw-r--r--browser/components/storybook/.storybook/fluent-utils.mjs122
-rw-r--r--browser/components/storybook/.storybook/l10n-pseudo.mjs110
-rw-r--r--browser/components/storybook/.storybook/main.js157
-rw-r--r--browser/components/storybook/.storybook/markdown-story-loader.js133
-rw-r--r--browser/components/storybook/.storybook/preview-head.html22
-rw-r--r--browser/components/storybook/.storybook/preview.mjs111
19 files changed, 1267 insertions, 0 deletions
diff --git a/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs
new file mode 100644
index 0000000000..05d72146cb
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-component-status/StatusIndicator.mjs
@@ -0,0 +1,114 @@
+/* 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/. */
+
+// eslint-disable-next-line no-unused-vars
+import React from "react";
+import { useParameter } from "@storybook/api";
+import {
+ // eslint-disable-next-line no-unused-vars
+ Badge,
+ // eslint-disable-next-line no-unused-vars
+ WithTooltip,
+ // eslint-disable-next-line no-unused-vars
+ TooltipMessage,
+ // eslint-disable-next-line no-unused-vars
+ IconButton,
+} from "@storybook/components";
+import { TOOL_ID, STATUS_PARAM_KEY } from "./constants.mjs";
+
+const VALID_STATUS_MAP = {
+ stable: {
+ label: "Stable",
+ badgeType: "positive",
+ description:
+ "This component is widely used in Firefox, in both the chrome and in-content pages.",
+ },
+ "in-development": {
+ label: "In Development",
+ badgeType: "warning",
+ description:
+ "This component is in active development and starting to be used in Firefox. It may not yet be usable in both the chrome and in-content pages.",
+ },
+ unstable: {
+ label: "Unstable",
+ badgeType: "negative",
+ description:
+ "This component is still in the early stages of development and may not be ready for general use in Firefox.",
+ },
+};
+
+/**
+ * Displays a badge with the components status in the Storybook toolbar.
+ *
+ * Statuses are set via story parameters.
+ * We support either passing `status: "statusType"` for using defaults or
+ * `status: {
+ type: "stable" | "in-development" | "unstable",
+ description: "Your description here"
+ links: [
+ {
+ title: "Link title",
+ href: "www.example.com",
+ },
+ ],
+ }`
+ * when we want to customize the description or add links.
+ */
+export const StatusIndicator = () => {
+ let componentStatus = useParameter(STATUS_PARAM_KEY, null);
+ let statusData = VALID_STATUS_MAP[componentStatus?.type ?? componentStatus];
+
+ if (!componentStatus || !statusData) {
+ return "";
+ }
+
+ // The tooltip message is added/removed from the DOM when visibility changes.
+ // We need to update the aira-describedby button relationship accordingly.
+ let onVisibilityChange = isVisible => {
+ let button = document.getElementById("statusButton");
+ if (isVisible) {
+ button.setAttribute("aria-describedby", "statusMessage");
+ } else {
+ button.removeAttribute("aria-describedby");
+ }
+ };
+
+ let description = componentStatus.description || statusData.description;
+ let links = componentStatus.links || [];
+
+ return (
+ <WithTooltip
+ key={TOOL_ID}
+ placement="top"
+ trigger="click"
+ style={{
+ display: "flex",
+ }}
+ onVisibilityChange={onVisibilityChange}
+ tooltip={() => (
+ <div id="statusMessage">
+ <TooltipMessage
+ title={statusData.label}
+ desc={description}
+ links={links}
+ />
+ </div>
+ )}
+ >
+ <IconButton
+ id="statusButton"
+ title={`Component status: ${statusData.label}`}
+ >
+ <Badge
+ status={statusData.badgeType}
+ style={{
+ boxShadow: "currentColor 0 0 0 1px inset",
+ }}
+ >
+ {statusData.label}
+ </Badge>
+ </IconButton>
+ </WithTooltip>
+ );
+};
diff --git a/browser/components/storybook/.storybook/addon-component-status/constants.mjs b/browser/components/storybook/.storybook/addon-component-status/constants.mjs
new file mode 100644
index 0000000000..84dc1983ac
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-component-status/constants.mjs
@@ -0,0 +1,7 @@
+/* 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 ADDON_ID = "addon-component-status";
+export const TOOL_ID = `${ADDON_ID}/statusIndicator`;
+export const STATUS_PARAM_KEY = "status";
diff --git a/browser/components/storybook/.storybook/addon-component-status/index.js b/browser/components/storybook/.storybook/addon-component-status/index.js
new file mode 100644
index 0000000000..7f923e2de1
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-component-status/index.js
@@ -0,0 +1,23 @@
+/* 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/. */
+/* eslint-env node */
+
+/**
+ * This file hooks our addon into Storybook. Having a root-level file like this
+ * is a Storybook requirement. It handles registering the addon without any
+ * additional user configuration.
+ */
+
+function config(entry = []) {
+ return [...entry, require.resolve("./preset/preview.mjs")];
+}
+
+function managerEntries(entry = []) {
+ return [...entry, require.resolve("./preset/manager.mjs")];
+}
+
+module.exports = {
+ managerEntries,
+ config,
+};
diff --git a/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs
new file mode 100644
index 0000000000..4aa611b156
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-component-status/preset/manager.mjs
@@ -0,0 +1,19 @@
+/* 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/. */
+
+/** This file handles registering the Storybook addon */
+
+// eslint-disable-next-line no-unused-vars
+import React from "react";
+import { addons, types } from "@storybook/addons";
+import { ADDON_ID, TOOL_ID } from "../constants.mjs";
+import { StatusIndicator } from "../StatusIndicator.mjs";
+
+addons.register(ADDON_ID, () => {
+ addons.add(TOOL_ID, {
+ type: types.TOOL,
+ title: "Pseudo Localization",
+ render: StatusIndicator,
+ });
+});
diff --git a/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs b/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs
new file mode 100644
index 0000000000..57f1378af6
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-component-status/preset/preview.mjs
@@ -0,0 +1,12 @@
+/* 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 globalTypes = {
+ componentStatus: {
+ name: "Component status",
+ description:
+ "Provides a visual indicator of the component's readiness status.",
+ defaultValue: "default",
+ },
+};
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/FluentPanel.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/FluentPanel.mjs
new file mode 100644
index 0000000000..5718ec5601
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/FluentPanel.mjs
@@ -0,0 +1,79 @@
+/* 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";
+import { addons } from "@storybook/addons";
+// eslint-disable-next-line no-unused-vars
+import { AddonPanel } from "@storybook/components";
+import { FLUENT_CHANGED, FLUENT_SET_STRINGS } from "./constants.mjs";
+
+export class FluentPanel extends React.Component {
+ constructor(props) {
+ super(props);
+ this.channel = addons.getChannel();
+ this.state = {
+ name: null,
+ strings: [],
+ };
+ }
+
+ componentDidMount() {
+ const { api } = this.props;
+ api.on(FLUENT_CHANGED, this.handleFluentChanged);
+ }
+
+ componentWillUnmount() {
+ const { api } = this.props;
+ api.off(FLUENT_CHANGED, this.handleFluentChanged);
+ }
+
+ handleFluentChanged = strings => {
+ let storyData = this.props.api.getCurrentStoryData();
+ let fileName = `${storyData.component}.ftl`;
+ this.setState(state => ({ ...state, strings, fileName }));
+ };
+
+ onInput = e => {
+ this.setState(state => {
+ let strings = [];
+ for (let [key, value] of state.strings) {
+ if (key == e.target.name) {
+ strings.push([key, e.target.value]);
+ } else {
+ strings.push([key, value]);
+ }
+ }
+ let stringified = strings
+ .map(([key, value]) => `${key} = ${value}`)
+ .join("\n");
+ this.channel.emit(FLUENT_SET_STRINGS, stringified);
+ const { fluentStrings } = this.props.api.getGlobals();
+ this.props.api.updateGlobals({
+ fluentStrings: { ...fluentStrings, [state.fileName]: strings },
+ });
+ return { ...state, strings };
+ });
+ };
+
+ render() {
+ const { api, active } = this.props;
+ const { strings } = this.state;
+ return (
+ <AddonPanel active={!!active} api={api}>
+ {strings.map(([identifier, value]) => (
+ <div key={identifier}>
+ <label>
+ {identifier} =
+ <textarea
+ name={identifier}
+ onInput={this.onInput}
+ defaultValue={value}
+ ></textarea>
+ </label>
+ </div>
+ ))}
+ </AddonPanel>
+ );
+ }
+}
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/PseudoLocalizationButton.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/PseudoLocalizationButton.mjs
new file mode 100644
index 0000000000..d60112d224
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/PseudoLocalizationButton.mjs
@@ -0,0 +1,55 @@
+/* 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/. */
+
+// eslint-disable-next-line no-unused-vars
+import React from "react";
+import { useGlobals } from "@storybook/api";
+import {
+ // eslint-disable-next-line no-unused-vars
+ Icons,
+ // eslint-disable-next-line no-unused-vars
+ IconButton,
+ // eslint-disable-next-line no-unused-vars
+ WithTooltip,
+ // eslint-disable-next-line no-unused-vars
+ TooltipLinkList,
+} from "@storybook/components";
+import { TOOL_ID, STRATEGY_DEFAULT, PSEUDO_STRATEGIES } from "./constants.mjs";
+
+// React component for a button + tooltip that gets added to the Storybook toolbar.
+export const PseudoLocalizationButton = () => {
+ const [{ pseudoStrategy = STRATEGY_DEFAULT }, updateGlobals] = useGlobals();
+
+ const updatePseudoStrategy = strategy => {
+ updateGlobals({ pseudoStrategy: strategy });
+ };
+
+ const getTooltipLinks = ({ onHide }) => {
+ return PSEUDO_STRATEGIES.map(strategy => ({
+ id: strategy,
+ title: strategy.charAt(0).toUpperCase() + strategy.slice(1),
+ onClick: () => {
+ updatePseudoStrategy(strategy);
+ onHide();
+ },
+ active: pseudoStrategy === strategy,
+ }));
+ };
+
+ return (
+ <WithTooltip
+ placement="top"
+ trigger="click"
+ tooltip={props => <TooltipLinkList links={getTooltipLinks(props)} />}
+ >
+ <IconButton
+ key={TOOL_ID}
+ active={pseudoStrategy && pseudoStrategy !== STRATEGY_DEFAULT}
+ title="Apply pseudo localization"
+ >
+ <Icons icon="transfer" />
+ </IconButton>
+ </WithTooltip>
+ );
+};
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/constants.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/constants.mjs
new file mode 100644
index 0000000000..7d059593dd
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/constants.mjs
@@ -0,0 +1,32 @@
+/* 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 ADDON_ID = "addon-pseudo-localization";
+export const PANEL_ID = `${ADDON_ID}/fluentPanel`;
+export const TOOL_ID = `${ADDON_ID}/toolbarButton`;
+
+export const STRATEGY_DEFAULT = "default";
+export const STRATEGY_ACCENTED = "accented";
+export const STRATEGY_BIDI = "bidi";
+
+export const PSEUDO_STRATEGIES = [
+ STRATEGY_DEFAULT,
+ STRATEGY_ACCENTED,
+ STRATEGY_BIDI,
+];
+
+export const DIRECTIONS = {
+ ltr: "ltr",
+ rtl: "rtl",
+};
+
+export const DIRECTION_BY_STRATEGY = {
+ [STRATEGY_DEFAULT]: DIRECTIONS.ltr,
+ [STRATEGY_ACCENTED]: DIRECTIONS.ltr,
+ [STRATEGY_BIDI]: DIRECTIONS.rtl,
+};
+
+export const UPDATE_STRATEGY_EVENT = "update-strategy";
+export const FLUENT_SET_STRINGS = "fluent-set-strings";
+export const FLUENT_CHANGED = "fluent-changed";
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/index.js b/browser/components/storybook/.storybook/addon-pseudo-localization/index.js
new file mode 100644
index 0000000000..7f923e2de1
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/index.js
@@ -0,0 +1,23 @@
+/* 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/. */
+/* eslint-env node */
+
+/**
+ * This file hooks our addon into Storybook. Having a root-level file like this
+ * is a Storybook requirement. It handles registering the addon without any
+ * additional user configuration.
+ */
+
+function config(entry = []) {
+ return [...entry, require.resolve("./preset/preview.mjs")];
+}
+
+function managerEntries(entry = []) {
+ return [...entry, require.resolve("./preset/manager.mjs")];
+}
+
+module.exports = {
+ managerEntries,
+ config,
+};
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/preset/manager.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/preset/manager.mjs
new file mode 100644
index 0000000000..0f7ff9299b
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/preset/manager.mjs
@@ -0,0 +1,34 @@
+/* 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/. */
+
+/** This file handles registering the Storybook addon */
+
+// eslint-disable-next-line no-unused-vars
+import React from "react";
+import { addons, types } from "@storybook/addons";
+import { ADDON_ID, PANEL_ID, TOOL_ID } from "../constants.mjs";
+import { PseudoLocalizationButton } from "../PseudoLocalizationButton.mjs";
+// eslint-disable-next-line no-unused-vars
+import { FluentPanel } from "../FluentPanel.mjs";
+
+// Register the addon.
+addons.register(ADDON_ID, api => {
+ // Register the tool.
+ addons.add(TOOL_ID, {
+ type: types.TOOL,
+ title: "Pseudo Localization",
+ // Toolbar button doesn't show on the "Docs" tab.
+ match: ({ viewMode }) => !!(viewMode && viewMode.match(/^story$/)),
+ render: PseudoLocalizationButton,
+ });
+
+ addons.add(PANEL_ID, {
+ title: "Fluent",
+ //👇 Sets the type of UI element in Storybook
+ type: types.PANEL,
+ render: ({ active, key }) => (
+ <FluentPanel active={active} api={api} key={key}></FluentPanel>
+ ),
+ });
+});
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/preset/preview.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/preset/preview.mjs
new file mode 100644
index 0000000000..cf4f135d40
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/preset/preview.mjs
@@ -0,0 +1,27 @@
+/* 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/. */
+
+/**
+ * This file provides global decorators for the Storybook addon. In theory we
+ * could combine multiple decorators, but for now we only need one.
+ */
+
+import {
+ withPseudoLocalization,
+ withFluentStrings,
+} from "../withPseudoLocalization.mjs";
+
+export const decorators = [withPseudoLocalization, withFluentStrings];
+export const globalTypes = {
+ pseudoStrategy: {
+ name: "Pseudo l10n strategy",
+ description: "Provides text variants for testing different locales.",
+ defaultValue: "default",
+ },
+ fluentStrings: {
+ name: "Fluent string map for components",
+ description: "Mapping of component to fluent strings.",
+ defaultValue: {},
+ },
+};
diff --git a/browser/components/storybook/.storybook/addon-pseudo-localization/withPseudoLocalization.mjs b/browser/components/storybook/.storybook/addon-pseudo-localization/withPseudoLocalization.mjs
new file mode 100644
index 0000000000..9d6c62af38
--- /dev/null
+++ b/browser/components/storybook/.storybook/addon-pseudo-localization/withPseudoLocalization.mjs
@@ -0,0 +1,89 @@
+/* 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 { useEffect, useGlobals, addons } from "@storybook/addons";
+import {
+ DIRECTIONS,
+ DIRECTION_BY_STRATEGY,
+ UPDATE_STRATEGY_EVENT,
+ FLUENT_CHANGED,
+} from "./constants.mjs";
+import { provideFluent } from "../fluent-utils.mjs";
+
+/**
+ * withPseudoLocalization is a Storybook decorator that handles emitting an
+ * event to update translations when a new pseudo localization strategy is
+ * applied. It also handles setting a "dir" attribute on the root element in the
+ * Storybook iframe.
+ *
+ * @param {Function} StoryFn - Provided by Storybook, used to render the story.
+ * @param {Object} context - Provided by Storybook, data about the story.
+ * @returns {Function} StoryFn with a modified "dir" attr set.
+ */
+export const withPseudoLocalization = (StoryFn, context) => {
+ const [{ pseudoStrategy }] = useGlobals();
+ const direction = DIRECTION_BY_STRATEGY[pseudoStrategy] || DIRECTIONS.ltr;
+ const isInDocs = context.viewMode === "docs";
+ const channel = addons.getChannel();
+
+ useEffect(() => {
+ if (pseudoStrategy) {
+ channel.emit(UPDATE_STRATEGY_EVENT, pseudoStrategy);
+ }
+ }, [pseudoStrategy]);
+
+ useEffect(() => {
+ if (isInDocs) {
+ document.documentElement.setAttribute("dir", DIRECTIONS.ltr);
+ let storyElements = document.querySelectorAll(".docs-story");
+ storyElements.forEach(element => element.setAttribute("dir", direction));
+ } else {
+ document.documentElement.setAttribute("dir", direction);
+ }
+ }, [direction, isInDocs]);
+
+ return StoryFn();
+};
+
+/**
+ * withFluentStrings is a Storybook decorator that handles emitting an
+ * event to update the Fluent strings shown in the Fluent panel.
+ *
+ * @param {Function} StoryFn - Provided by Storybook, used to render the story.
+ * @param {Object} context - Provided by Storybook, data about the story.
+ * @returns {Function} StoryFn unmodified.
+ */
+export const withFluentStrings = (StoryFn, context) => {
+ const [{ fluentStrings }, updateGlobals] = useGlobals();
+ const channel = addons.getChannel();
+
+ const fileName = context.component + ".ftl";
+ let strings = [];
+
+ if (context.parameters?.fluent && fileName) {
+ if (fluentStrings.hasOwnProperty(fileName)) {
+ strings = fluentStrings[fileName];
+ } else {
+ let resource = provideFluent(context.parameters.fluent, fileName);
+ for (let message of resource.body) {
+ strings.push([
+ message.id,
+ [
+ message.value,
+ ...Object.entries(message.attributes).map(
+ ([key, value]) => ` .${key} = ${value}`
+ ),
+ ].join("\n"),
+ ]);
+ }
+ updateGlobals({
+ fluentStrings: { ...fluentStrings, [fileName]: strings },
+ });
+ }
+ }
+
+ channel.emit(FLUENT_CHANGED, strings);
+
+ return StoryFn();
+};
diff --git a/browser/components/storybook/.storybook/chrome-uri-loader.js b/browser/components/storybook/.storybook/chrome-uri-loader.js
new file mode 100644
index 0000000000..b558732152
--- /dev/null
+++ b/browser/components/storybook/.storybook/chrome-uri-loader.js
@@ -0,0 +1,98 @@
+/* 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/. */
+/* eslint-env node */
+
+/**
+ * This file contains a webpack loader which has the goal of rewriting chrome://
+ * URIs to local paths. This allows JS files loaded in Storybook to load JS
+ * files using their chrome:// URI. Using the chrome:// URI is avoidable in many
+ * cases, however in some cases such as importing the lit.all.mjs file from
+ * browser/components/ there is no way to avoid it on the Firefox side.
+ *
+ * This loader depends on the `./mach storybook manifest` step to generate the
+ * rewrites.js file. That file exports an object with the files we know how to
+ * rewrite chrome:// URIs for.
+ *
+ * This loader allows code like this to work with storybook:
+ *
+ * import { html } from "chrome://global/content/vendor/lit.all.mjs";
+ * import "chrome://global/content/elements/moz-button-group.mjs";
+ *
+ * In this example the file would be rewritten in the webpack bundle as:
+ *
+ * import { html } from "toolkit/content/widgets/vendor/lit.all.mjs";
+ * import "toolkit/content/widgets/moz-button-group/moz-button-group.mjs";
+ */
+
+const path = require("path");
+
+// Object<ChromeURI, LocalPath> - This is generated by `./mach storybook manifest`.
+const rewrites = require("./rewrites.js");
+
+const projectRoot = path.join(process.cwd(), "../../..");
+
+/**
+ * Return an array of the unique chrome:// URIs referenced in this file.
+ *
+ * @param {string} source - The source file to scan.
+ * @returns {string[]} Unique list of chrome:// URIs
+ */
+function getReferencedChromeUris(source) {
+ // We can only rewrite files that get imported. Which means currently we only
+ // support .js and .mjs. In the future we hope to rewrite .css and .svg.
+ const chromeRegex = /chrome:\/\/.*?\.(js|mjs)/g;
+ const matches = new Set();
+ for (let match of source.matchAll(chromeRegex)) {
+ // Add the full URI to the set of matches.
+ matches.add(match[0]);
+ }
+ return [...matches];
+}
+
+/**
+ * Replace references to chrome:// URIs with the relative path on disk from the
+ * project root.
+ *
+ * @this {WebpackLoader} https://webpack.js.org/api/loaders/
+ * @param {string} source - The source file to update.
+ * @returns {string} The updated source.
+ */
+async function rewriteChromeUris(source) {
+ const chromeUriToLocalPath = new Map();
+ // We're going to rewrite the chrome:// URIs, find all referenced URIs.
+ let chromeDependencies = getReferencedChromeUris(source);
+ for (let chromeUri of chromeDependencies) {
+ let localRelativePath = rewrites[chromeUri];
+ if (localRelativePath) {
+ localRelativePath = localRelativePath.replaceAll("\\", "/");
+ // Store the mapping to a local path for this chrome URI.
+ chromeUriToLocalPath.set(chromeUri, localRelativePath);
+ // Tell webpack the file being handled depends on the referenced file.
+ this.addDependency(path.join(projectRoot, localRelativePath));
+ }
+ }
+ // Rewrite the source file with mapped chrome:// URIs.
+ let rewrittenSource = source;
+ for (let [chromeUri, localPath] of chromeUriToLocalPath.entries()) {
+ rewrittenSource = rewrittenSource.replaceAll(chromeUri, localPath);
+ }
+ return rewrittenSource;
+}
+
+/**
+ * The WebpackLoader export. Runs async since apparently that's preferred.
+ *
+ * @param {string} source - The source to rewrite.
+ * @param {Map} sourceMap - Source map data, unused.
+ * @param {Object} meta - Metadata, unused.
+ */
+module.exports = async function chromeUriLoader(source) {
+ // Get a callback to tell webpack when we're done.
+ const callback = this.async();
+ // Rewrite the source async since that appears to be preferred (and will be
+ // necessary once we support rewriting CSS/SVG/etc).
+ const newSource = await rewriteChromeUris.call(this, source);
+ // Give webpack the rewritten content.
+ callback(null, newSource);
+};
diff --git a/browser/components/storybook/.storybook/fluent-utils.mjs b/browser/components/storybook/.storybook/fluent-utils.mjs
new file mode 100644
index 0000000000..f38e99770b
--- /dev/null
+++ b/browser/components/storybook/.storybook/fluent-utils.mjs
@@ -0,0 +1,122 @@
+/* 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 { DOMLocalization } from "@fluent/dom";
+import { FluentBundle, FluentResource } from "@fluent/bundle";
+import { addons } from "@storybook/addons";
+import { PSEUDO_STRATEGY_TRANSFORMS } from "./l10n-pseudo.mjs";
+import {
+ FLUENT_SET_STRINGS,
+ UPDATE_STRATEGY_EVENT,
+ STRATEGY_DEFAULT,
+ PSEUDO_STRATEGIES,
+} from "./addon-pseudo-localization/constants.mjs";
+
+let loadedResources = new Map();
+let currentStrategy;
+let storybookBundle = new FluentBundle("en-US", {
+ transform(str) {
+ if (currentStrategy in PSEUDO_STRATEGY_TRANSFORMS) {
+ return PSEUDO_STRATEGY_TRANSFORMS[currentStrategy](str);
+ }
+ return str;
+ },
+});
+
+// Listen for update events from addon-pseudo-localization.
+const channel = addons.getChannel();
+channel.on(UPDATE_STRATEGY_EVENT, updatePseudoStrategy);
+channel.on(FLUENT_SET_STRINGS, ftlContents => {
+ let resource = new FluentResource(ftlContents);
+ for (let message of resource.body) {
+ let existingMessage = storybookBundle.getMessage(message.id);
+ existingMessage.value = message.value;
+ existingMessage.attributes = message.attributes;
+ }
+ document.l10n.translateRoots();
+});
+
+/**
+ * Updates "currentStrategy" when the selected pseudo localization strategy
+ * changes, which in turn changes the transform used by the Fluent bundle.
+ *
+ * @param {string} strategy
+ * Pseudo localization strategy. Can be "default", "accented", or "bidi".
+ */
+function updatePseudoStrategy(strategy = STRATEGY_DEFAULT) {
+ if (strategy !== currentStrategy && PSEUDO_STRATEGIES.includes(strategy)) {
+ currentStrategy = strategy;
+ document.l10n.translateRoots();
+ }
+}
+
+export function connectFluent() {
+ document.l10n = new DOMLocalization([], generateBundles);
+ document.l10n.connectRoot(document.documentElement);
+ document.l10n.translateRoots();
+}
+
+function* generateBundles() {
+ yield* [storybookBundle];
+}
+
+export async function insertFTLIfNeeded(fileName) {
+ if (loadedResources.has(fileName)) {
+ return;
+ }
+
+ // This should be browser, locales-preview or toolkit.
+ let [root, ...rest] = fileName.split("/");
+ let ftlContents;
+
+ // TODO(mstriemer): These seem like they could be combined but I don't want
+ // to fight with webpack anymore.
+ if (root == "toolkit") {
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /.*[\/\\].*\.ftl$/ */
+ `toolkit/locales/en-US/${fileName}`
+ );
+ ftlContents = imported.default;
+ } else if (root == "browser") {
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /.*[\/\\].*\.ftl$/ */
+ `browser/locales/en-US/${fileName}`
+ );
+ ftlContents = imported.default;
+ } else if (root == "locales-preview") {
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /\.ftl$/ */
+ `browser/locales-preview/${rest}`
+ );
+ ftlContents = imported.default;
+ } else if (root == "branding") {
+ // eslint-disable-next-line no-unsanitized/method
+ let imported = await import(
+ /* webpackInclude: /\.ftl$/ */
+ `browser/branding/nightly/locales/en-US/${rest}`
+ );
+ ftlContents = imported.default;
+ }
+
+ if (loadedResources.has(fileName)) {
+ // Seems possible we've attempted to load this twice before the first call
+ // resolves, so once the first load is complete we can abandon the others.
+ return;
+ }
+
+ provideFluent(ftlContents, fileName);
+}
+
+export function provideFluent(ftlContents, fileName) {
+ let ftlResource = new FluentResource(ftlContents);
+ storybookBundle.addResource(ftlResource);
+ if (fileName) {
+ loadedResources.set(fileName, ftlResource);
+ }
+ document.l10n.translateRoots();
+ return ftlResource;
+}
diff --git a/browser/components/storybook/.storybook/l10n-pseudo.mjs b/browser/components/storybook/.storybook/l10n-pseudo.mjs
new file mode 100644
index 0000000000..c92be262e9
--- /dev/null
+++ b/browser/components/storybook/.storybook/l10n-pseudo.mjs
@@ -0,0 +1,110 @@
+/* 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/. */
+
+// Stolen from https://github.com/firefox-devtools/profiler/blob/52a7531662a08d96dc8bd03d25adcdb4c9653b92/src/utils/l10n-pseudo.js
+// Which was stolen from https://hg.mozilla.org/mozilla-central/file/a1f74e8c8fb72390d22054d6b00c28b1a32f6c43/intl/l10n/L10nRegistry.jsm#l425
+
+/**
+ * Pseudolocalizations
+ *
+ * PSEUDO_STRATEGIES is a dict of strategies to be used to modify a
+ * context in order to create pseudolocalizations. These can be used by
+ * developers to test the localizability of their code without having to
+ * actually speak a foreign language.
+ *
+ * Currently, the following pseudolocales are supported:
+ *
+ * accented - Ȧȧƈƈḗḗƞŧḗḗḓ Ḗḗƞɠŀīīşħ
+ *
+ * In Accented English all Latin letters are replaced by accented
+ * Unicode counterparts which don't impair the readability of the content.
+ * This allows developers to quickly test if any given string is being
+ * correctly displayed in its 'translated' form. Additionally, simple
+ * heuristics are used to make certain words longer to better simulate the
+ * experience of international users.
+ *
+ * bidi - ɥsıʅƃuƎ ıpıԐ
+ *
+ * Bidi English is a fake RTL locale. All words are surrounded by
+ * Unicode formatting marks forcing the RTL directionality of characters.
+ * In addition, to make the reversed text easier to read, individual
+ * letters are flipped.
+ *
+ * Note: The name above is hardcoded to be RTL in case code editors have
+ * trouble with the RLO and PDF Unicode marks. In reality, it should be
+ * surrounded by those marks as well.
+ *
+ * See https://bugzil.la/1450781 for more information.
+ *
+ * In this implementation we use code points instead of inline unicode characters
+ * because the encoding of JSM files mangles them otherwise.
+ */
+
+const ACCENTED_MAP = {
+ // ȦƁƇḒḖƑƓĦĪĴĶĿḾȠǾƤɊŘŞŦŬṼẆẊẎẐ
+ // prettier-ignore
+ "caps": [550, 385, 391, 7698, 7702, 401, 403, 294, 298, 308, 310, 319, 7742, 544, 510, 420, 586, 344, 350, 358, 364, 7804, 7814, 7818, 7822, 7824],
+ // ȧƀƈḓḗƒɠħīĵķŀḿƞǿƥɋřşŧŭṽẇẋẏẑ
+ // prettier-ignore
+ "small": [551, 384, 392, 7699, 7703, 402, 608, 295, 299, 309, 311, 320, 7743, 414, 511, 421, 587, 345, 351, 359, 365, 7805, 7815, 7819, 7823, 7825],
+};
+
+const FLIPPED_MAP = {
+ // ∀ԐↃᗡƎℲ⅁HIſӼ⅂WNOԀÒᴚS⊥∩ɅMX⅄Z
+ // prettier-ignore
+ "caps": [8704, 1296, 8579, 5601, 398, 8498, 8513, 72, 73, 383, 1276, 8514, 87, 78, 79, 1280, 210, 7450, 83, 8869, 8745, 581, 77, 88, 8516, 90],
+ // ɐqɔpǝɟƃɥıɾʞʅɯuodbɹsʇnʌʍxʎz
+ // prettier-ignore
+ "small": [592, 113, 596, 112, 477, 607, 387, 613, 305, 638, 670, 645, 623, 117, 111, 100, 98, 633, 115, 647, 110, 652, 653, 120, 654, 122],
+};
+
+function transformString(
+ map = FLIPPED_MAP,
+ elongate = false,
+ prefix = "",
+ postfix = "",
+ msg
+) {
+ // Exclude access-keys and other single-char messages
+ if (msg.length === 1) {
+ return msg;
+ }
+ // XML entities (&#x202a;) and XML tags.
+ const reExcluded = /(&[#\w]+;|<\s*.+?\s*>)/;
+
+ const parts = msg.split(reExcluded);
+ const modified = parts.map(part => {
+ if (reExcluded.test(part)) {
+ return part;
+ }
+ return (
+ prefix +
+ part.replace(/[a-z]/gi, ch => {
+ const cc = ch.charCodeAt(0);
+ if (cc >= 97 && cc <= 122) {
+ const newChar = String.fromCodePoint(map.small[cc - 97]);
+ // duplicate "a", "e", "o" and "u" to emulate ~30% longer text
+ if (
+ elongate &&
+ (cc === 97 || cc === 101 || cc === 111 || cc === 117)
+ ) {
+ return newChar + newChar;
+ }
+ return newChar;
+ }
+ if (cc >= 65 && cc <= 90) {
+ return String.fromCodePoint(map.caps[cc - 65]);
+ }
+ return ch;
+ }) +
+ postfix
+ );
+ });
+ return modified.join("");
+}
+
+export const PSEUDO_STRATEGY_TRANSFORMS = {
+ accented: transformString.bind(null, ACCENTED_MAP, true, "", ""),
+ bidi: transformString.bind(null, FLIPPED_MAP, false, "\u202e", "\u202c"),
+};
diff --git a/browser/components/storybook/.storybook/main.js b/browser/components/storybook/.storybook/main.js
new file mode 100644
index 0000000000..5207172b6e
--- /dev/null
+++ b/browser/components/storybook/.storybook/main.js
@@ -0,0 +1,157 @@
+/* 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/. */
+/* eslint-env node */
+
+const path = require("path");
+const webpack = require("webpack");
+
+const [prefixMap, aliasMap, sourceMap] = require("./chrome-map.js");
+
+const projectRoot = path.resolve(__dirname, "../../../../");
+
+function rewriteChromeUri(uri) {
+ if (uri in aliasMap) {
+ return rewriteChromeUri(aliasMap[uri]);
+ }
+ for (let [prefix, [bundlePath]] of Object.entries(prefixMap)) {
+ if (uri.startsWith(prefix)) {
+ if (!bundlePath.endsWith("/")) {
+ bundlePath += "/";
+ }
+ let relativePath = uri.slice(prefix.length);
+ let objdirPath = bundlePath + relativePath;
+ for (let [_objdirPath, [filePath]] of Object.entries(sourceMap)) {
+ if (_objdirPath == objdirPath) {
+ // We're just hoping this is the actual path =\
+ return filePath;
+ }
+ }
+ }
+ }
+ return "";
+}
+
+module.exports = {
+ // The ordering for this stories array affects the order that they are displayed in Storybook
+ stories: [
+ // Docs section
+ "../**/README.*.stories.md",
+ // UI Widgets section
+ `${projectRoot}/toolkit/content/widgets/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`,
+ // Everything else
+ "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|md)",
+ ],
+ // Additions to the staticDirs might also need to get added to
+ // MozXULElement.importCss in preview.mjs to enable auto-reloading.
+ staticDirs: [
+ `${projectRoot}/toolkit/content/widgets/`,
+ `${projectRoot}/browser/themes/shared/`,
+ `${projectRoot}/browser/components/firefoxview/`,
+ ],
+ addons: [
+ "@storybook/addon-links",
+ {
+ name: "@storybook/addon-essentials",
+ options: {
+ backgrounds: false,
+ measure: false,
+ outline: false,
+ },
+ },
+ "@storybook/addon-a11y",
+ path.resolve(__dirname, "addon-pseudo-localization"),
+ path.resolve(__dirname, "addon-component-status"),
+ ],
+ framework: "@storybook/web-components",
+ webpackFinal: async (config, { configType }) => {
+ // `configType` has a value of 'DEVELOPMENT' or 'PRODUCTION'
+ // You can change the configuration based on that.
+ // 'PRODUCTION' is used when building the static version of storybook.
+
+ // Make whatever fine-grained changes you need
+ config.resolve.alias.browser = `${projectRoot}/browser`;
+ config.resolve.alias.toolkit = `${projectRoot}/toolkit`;
+ config.resolve.alias[
+ "toolkit-widgets"
+ ] = `${projectRoot}/toolkit/content/widgets/`;
+ config.resolve.alias[
+ "lit.all.mjs"
+ ] = `${projectRoot}/toolkit/content/widgets/vendor/lit.all.mjs`;
+ // @mdx-js/react@1.x.x versions don't get hoisted to the root node_modules
+ // folder due to the versions of React it accepts as a peer dependency. That
+ // means we have to go one level deeper and look in the node_modules of
+ // @storybook/addon-docs, which depends on @mdx-js/react.
+ config.resolve.alias["@storybook/addon-docs"] =
+ "browser/components/storybook/node_modules/@storybook/addon-docs";
+ config.resolve.alias["@mdx-js/react"] =
+ "@storybook/addon-docs/node_modules/@mdx-js/react";
+
+ // The @storybook/web-components project uses lit-html. Redirect it to our
+ // bundled version.
+ config.resolve.alias["lit-html/directive-helpers.js"] = "lit.all.mjs";
+ config.resolve.alias["lit-html"] = "lit.all.mjs";
+
+ config.plugins.push(
+ // Rewrite chrome:// URI imports to file system paths.
+ new webpack.NormalModuleReplacementPlugin(/^chrome:\/\//, resource => {
+ resource.request = rewriteChromeUri(resource.request);
+ })
+ );
+
+ config.module.rules.push({
+ test: /\.ftl$/,
+ type: "asset/source",
+ });
+
+ // We're adding a rule for files matching this pattern in order to support
+ // writing docs only stories in plain markdown.
+ const MD_STORY_REGEX = /(stories|story)\.md$/;
+
+ // Find the existing rule for MDX stories.
+ let mdxStoryTest = /(stories|story)\.mdx$/.toString();
+ let mdxRule = config.module.rules.find(
+ rule => rule.test.toString() === mdxStoryTest
+ );
+
+ // Use a custom Webpack loader to transform our markdown stories into MDX,
+ // then run our new MDX through the same loaders that Storybook usually uses
+ // for MDX files. This is how we get a docs page from plain markdown.
+ config.module.rules.push({
+ test: MD_STORY_REGEX,
+ use: [
+ ...mdxRule.use,
+ { loader: path.resolve(__dirname, "./markdown-story-loader.js") },
+ ],
+ });
+
+ // Find the existing rule for markdown files.
+ let markdownTest = /\.md$/.toString();
+ let markdownRuleIndex = config.module.rules.findIndex(
+ rule => rule.test.toString() === markdownTest
+ );
+ let markdownRule = config.module.rules[markdownRuleIndex];
+
+ // Modify the existing markdown rule so it doesn't process .stories.md
+ // files, but still treats any other markdown files as asset/source.
+ config.module.rules[markdownRuleIndex] = {
+ ...markdownRule,
+ exclude: MD_STORY_REGEX,
+ };
+
+ config.optimization = {
+ splitChunks: false,
+ runtimeChunk: false,
+ sideEffects: false,
+ usedExports: false,
+ concatenateModules: false,
+ minimizer: [],
+ };
+
+ // Return the altered config
+ return config;
+ },
+ core: {
+ builder: "webpack5",
+ },
+};
diff --git a/browser/components/storybook/.storybook/markdown-story-loader.js b/browser/components/storybook/.storybook/markdown-story-loader.js
new file mode 100644
index 0000000000..150db5639e
--- /dev/null
+++ b/browser/components/storybook/.storybook/markdown-story-loader.js
@@ -0,0 +1,133 @@
+/* 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/. */
+/* eslint-env node */
+
+/**
+ * This file contains a Webpack loader that takes markdown as its source and
+ * outputs a docs only MDX Storybook story. This enables us to write docs only
+ * pages in plain markdown by specifying a `.stories.md` extension.
+ *
+ * For more context on docs only stories, see:
+ * https://storybook.js.org/docs/web-components/writing-docs/mdx#documentation-only-mdx
+ *
+ * The MDX generated by the loader will then get run through the same loaders
+ * Storybook usually uses to transform MDX files.
+ */
+
+const path = require("path");
+const fs = require("fs");
+
+const projectRoot = path.resolve(__dirname, "../../../../");
+
+/**
+ * Takes a file path and returns a string to use as the story title, capitalized
+ * and split into multiple words. The file name gets transformed into the story
+ * name, which will be visible in the Storybook sidebar. For example, either:
+ *
+ * /stories/hello-world.stories.md or /stories/helloWorld.md
+ *
+ * will result in a story named "Hello World".
+ *
+ * @param {string} filePath - path of the file being processed.
+ * @returns {string} The title of the story.
+ */
+function getStoryTitle(filePath) {
+ let fileName = path.basename(filePath, ".stories.md");
+ if (fileName != "README") {
+ try {
+ let relatedFilePath = path.resolve(
+ "../../../",
+ filePath.replace(".md", ".mjs")
+ );
+ let relatedFile = fs.readFileSync(relatedFilePath).toString();
+ let relatedTitle = relatedFile.match(/title: "(.*)"/)[1];
+ if (relatedTitle) {
+ return relatedTitle + "/README";
+ }
+ } catch {}
+ }
+ return separateWords(fileName);
+}
+
+/**
+ * Splits a string into multiple capitalized words e.g. hello-world, helloWorld,
+ * and hello.world all become "Hello World."
+ * @param {string} str - String in any case.
+ * @returns {string} The string split into multiple words.
+ */
+function separateWords(str) {
+ return (
+ str
+ .match(/[A-Z]?[a-z0-9]+/g)
+ ?.map(text => text[0].toUpperCase() + text.substring(1))
+ .join(" ") || str
+ );
+}
+
+/**
+ * Enables rendering code in our markdown docs by parsing the source for
+ * annotated code blocks and replacing them with Storybook's Canvas component.
+ * @param {string} source - Stringified markdown source code.
+ * @returns {string} Source with code blocks replaced by Canvas components.
+ */
+function parseStoriesFromMarkdown(source) {
+ let storiesRegex = /```(?:js|html) story\n(?<code>[\s\S]*?)```/g;
+ // $code comes from the <code> capture group in the regex above. It consists
+ // of any code in between backticks and gets run when used in a Canvas component.
+ return source.replace(
+ storiesRegex,
+ "<Canvas withSource='none'><with-common-styles>$<code></with-common-styles></Canvas>"
+ );
+}
+
+/**
+ * The WebpackLoader export. Takes markdown as its source and returns a docs
+ * only MDX story. Falls back to filing stories under "Docs" for everything
+ * outside of `toolkit/content/widgets`.
+ *
+ * @param {string} source - The markdown source to rewrite to MDX.
+ */
+module.exports = function markdownStoryLoader(source) {
+ // Currently we sort docs only stories under "Docs" by default.
+ let storyPath = "Docs";
+
+ // `this.resourcePath` is the path of the file being processed.
+ let relativePath = path
+ .relative(projectRoot, this.resourcePath)
+ .replaceAll(path.sep, "/");
+
+ if (relativePath.includes("toolkit/content/widgets")) {
+ let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g;
+ let componentName = storyNameRegex.exec(relativePath)?.groups?.name;
+ if (componentName) {
+ // Get the common name for a component e.g. Toggle for moz-toggle
+ storyPath =
+ "UI Widgets/" + separateWords(componentName).replace(/^Moz/g, "");
+ }
+ }
+
+ let storyTitle = getStoryTitle(relativePath);
+ let title = storyTitle.includes("/")
+ ? storyTitle
+ : `${storyPath}/${storyTitle}`;
+
+ // Unfortunately the indentation/spacing here seems to be important for the
+ // MDX parser to know what to do in the next step of the Webpack process.
+ let mdxSource = `
+import { Meta, Description, Canvas } from "@storybook/addon-docs";
+
+<Meta
+ title="${title}"
+ parameters={{
+ previewTabs: {
+ canvas: { hidden: true },
+ },
+ viewMode: "docs",
+ }}
+/>
+
+${parseStoriesFromMarkdown(source)}`;
+
+ return mdxSource;
+};
diff --git a/browser/components/storybook/.storybook/preview-head.html b/browser/components/storybook/.storybook/preview-head.html
new file mode 100644
index 0000000000..2a64d15690
--- /dev/null
+++ b/browser/components/storybook/.storybook/preview-head.html
@@ -0,0 +1,22 @@
+<!-- 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/. -->
+<link rel="stylesheet" href="chrome://global/skin/design-tokens-brand.css" />
+<style>
+ /* Override the default Storybook padding in favour of styles
+ provided by our WithCommonStyles wrapper */
+ .sb-show-main.sb-main-padded {
+ padding: 0;
+ }
+
+ /* Ensure WithCommonStyles can grow to fit the page */
+ #root-inner {
+ height: 100vh;
+ }
+
+ /* Docs stories are being given unnecessary height, possibly because we
+ turned off certain controls */
+ .docs-story div div {
+ height: unset;
+ }
+</style>
diff --git a/browser/components/storybook/.storybook/preview.mjs b/browser/components/storybook/.storybook/preview.mjs
new file mode 100644
index 0000000000..55e7f00f10
--- /dev/null
+++ b/browser/components/storybook/.storybook/preview.mjs
@@ -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 { css, html } from "lit.all.mjs";
+import { MozLitElement } from "toolkit/content/widgets/lit-utils.mjs";
+import { setCustomElementsManifest } from "@storybook/web-components";
+import customElementsManifest from "../custom-elements.json";
+import { insertFTLIfNeeded, connectFluent } from "./fluent-utils.mjs";
+
+// Base Fluent set up.
+connectFluent();
+
+// Any fluent imports should go through MozXULElement.insertFTLIfNeeded.
+window.MozXULElement = {
+ insertFTLIfNeeded,
+
+ // For some reason Storybook doesn't watch the static folder. By creating a
+ // method with a dynamic import we can pull the desired files into the bundle.
+ async importCss(resourceName) {
+ // eslint-disable-next-line no-unsanitized/method
+ let file = await import(
+ /* webpackInclude: /.*[\/\\].*\.css$/ */
+ `browser/themes/shared/${resourceName}`
+ );
+ // eslint-disable-next-line no-unsanitized/method
+ file = await import(
+ /* webpackInclude: /.*[\/\\].*\.css$/ */
+ `browser/components/firefoxview/${resourceName}`
+ );
+ return file;
+ },
+};
+
+/**
+ * Wrapper component used to decorate all of our stories by providing access to
+ * `in-content/common.css` without leaking styles that conflict Storybook's CSS.
+ *
+ * More information on decorators can be found at:
+ * https://storybook.js.org/docs/web-components/writing-stories/decorators
+ *
+ * @property {Function} story
+ * Storybook uses this internally to render stories. We call `story` in our
+ * render function so that the story contents have the same shadow root as
+ * `with-common-styles` and styles from `in-content/common` get applied.
+ * @property {Object} context
+ * Another Storybook provided property containing additional data stories use
+ * to render. If we don't make this a reactive property Lit seems to optimize
+ * away any re-rendering of components inside `with-common-styles`.
+ */
+class WithCommonStyles extends MozLitElement {
+ static styles = css`
+ :host {
+ display: block;
+ height: 100%;
+ padding: 1rem;
+ box-sizing: border-box;
+ }
+
+ :host,
+ :root {
+ font: message-box;
+ appearance: none;
+ background-color: var(--in-content-page-background);
+ color: var(--in-content-page-color);
+ -moz-box-layout: flex;
+ }
+
+ :host,
+ :root:not(.system-font-size) {
+ font-size: 15px;
+ }
+ `;
+
+ static properties = {
+ story: { type: Function },
+ context: { type: Object },
+ };
+
+ storyContent() {
+ if (this.story) {
+ return this.story();
+ }
+ return html` <slot></slot> `;
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ ${this.storyContent()}
+ `;
+ }
+}
+customElements.define("with-common-styles", WithCommonStyles);
+
+// Wrap all stories in `with-common-styles`.
+export const decorators = [
+ (story, context) =>
+ html`
+ <with-common-styles
+ .story=${story}
+ .context=${context}
+ ></with-common-styles>
+ `,
+];
+
+// Enable props tables documentation.
+setCustomElementsManifest(customElementsManifest);