diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /browser/components/storybook/.storybook | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/storybook/.storybook')
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 (‪) 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); |