diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/storybook/.storybook | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/storybook/.storybook')
21 files changed, 1664 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-fluent/FluentPanel.mjs b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs new file mode 100644 index 0000000000..692ff73737 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/FluentPanel.mjs @@ -0,0 +1,121 @@ +/* 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"; +// eslint-disable-next-line import/no-unassigned-import +import "./fluent-panel.css"; + +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) { + let stringValue = e.target.value; + if (stringValue.startsWith(".")) { + stringValue = "\n" + stringValue; + } + strings.push([key, stringValue]); + } 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; + if (strings.length === 0) { + return ( + <AddonPanel active={!!active} api={api}> + <div className="addon-panel-body"> + <div className="addon-panel-message"> + This story is not configured to use Fluent. + </div> + </div> + </AddonPanel> + ); + } + + return ( + <AddonPanel active={!!active} api={api}> + <div className="addon-panel-body"> + <table aria-hidden="false" className="addon-panel-table"> + <thead className="addon-panel-table-head"> + <tr> + <th> + <span>Identifier</span> + </th> + <th> + <span>String</span> + </th> + </tr> + </thead> + <tbody className="addon-panel-table-body"> + {strings.map(([identifier, value]) => ( + <tr key={identifier}> + <td> + <span>{identifier}</span> + </td> + <td> + <label> + <textarea + name={identifier} + onInput={this.onInput} + defaultValue={value + .trim() + .split("\n") + .map(s => s.trim()) + .join("\n")} + ></textarea> + </label> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </AddonPanel> + ); + } +} diff --git a/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs b/browser/components/storybook/.storybook/addon-fluent/PseudoLocalizationButton.mjs new file mode 100644 index 0000000000..d60112d224 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-fluent/constants.mjs b/browser/components/storybook/.storybook/addon-fluent/constants.mjs new file mode 100644 index 0000000000..3f00b2972a --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-fluent"; +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-fluent/fluent-panel.css b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css new file mode 100644 index 0000000000..75f4562820 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/fluent-panel.css @@ -0,0 +1,83 @@ +/* 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/. */ + +.addon-panel-body { + box-sizing: border-box; +} + +.addon-panel-message { + background: #FFF5CF; + color: #333333; + padding: 10px 15px; + line-height: 20px; + box-shadow: rgba(0,0,0,.1) 0 -1px 0 0 inset; + font-size: 13px; +} + +.addon-panel-table { + border-collapse: collapse; + border-spacing: 0; + color: #333333; + font-size: 13px; + line-height: 20px; + text-align: left; + width: 100%; + margin: 0; +} + +.addon-panel-table-head { + color: rgba(51,51,51,0.75); +} + +.addon-panel-table-head th { + padding: 10px 15px; + border: none; + vertical-align: top; +} + +.addon-panel-table-head th:first-of-type, .addon-panel-table-body td:first-of-type { + width: 25%; + padding-left: 20px; +} + +.addon-panel-table-head th:last-of-type, .addon-panel-table-body td:last-of-type { + padding-right: 20px; +} + +.addon-panel-table-body { + border-radius: 4px; +} + +.addon-panel-table-body tr { + overflow: hidden; + border-top: 1px solid #e6e6e6; +} + +.addon-panel-table-body td { + padding: 10px 15px; + font-weight: bold; +} + +.addon-panel-table-body label { + display: flex; +} + +.addon-panel-table-body textarea { + height: fit-content; + appearance: none; + border: none; + box-sizing: inherit; + display: block; + margin: 0; + background-color: rgb(255, 255, 255); + padding: 6px 10px; + color: #333333; + box-shadow: rgba(0,0,0,.1) 0 0 0 1px inset; + border-radius: 4px; + line-height: 20px; + flex: 1; + text-align: left; + overflow: visible; + max-height: 400px; +} diff --git a/browser/components/storybook/.storybook/addon-fluent/index.js b/browser/components/storybook/.storybook/addon-fluent/index.js new file mode 100644 index 0000000000..7f923e2de1 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-fluent/preset/manager.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/manager.mjs new file mode 100644 index 0000000000..0f7ff9299b --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-fluent/preset/preview.mjs b/browser/components/storybook/.storybook/addon-fluent/preset/preview.mjs new file mode 100644 index 0000000000..cf4f135d40 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-fluent/withPseudoLocalization.mjs b/browser/components/storybook/.storybook/addon-fluent/withPseudoLocalization.mjs new file mode 100644 index 0000000000..9d6c62af38 --- /dev/null +++ b/browser/components/storybook/.storybook/addon-fluent/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-styles-loader.js b/browser/components/storybook/.storybook/chrome-styles-loader.js new file mode 100644 index 0000000000..4a66128320 --- /dev/null +++ b/browser/components/storybook/.storybook/chrome-styles-loader.js @@ -0,0 +1,145 @@ +/* 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 rewrites JS source files to use CSS + * imports when running in Storybook. This allows JS files loaded in Storybook to use + * chrome:// URIs when loading external stylesheets without having to worry + * about Storybook being able to find and detect changes to the files. + * + * This loader allows Lit-based custom element code like this to work with + * Storybook: + * + * render() { + * return html` + * <link rel="stylesheet" href="chrome://global/content/elements/moz-toggle.css" /> + * ... + * `; + * } + * + * By rewriting the source to this: + * + * import moztoggleStyles from "toolkit/content/widgets/moz-toggle/moz-toggle.css"; + * ... + * render() { + * return html` + * <link rel="stylesheet" href=${moztoggleStyles} /> + * ... + * `; + * } + * + * It works similarly for vanilla JS custom elements that utilize template + * strings. The following code: + * + * static get markup() { + * return` + * <template> + * <link rel="stylesheet" href="chrome://browser/skin/migration/migration-wizard.css"> + * ... + * </template> + * `; + * } + * + * Gets rewritten to: + * + * import migrationwizardStyles from "browser/themes/shared/migration/migration-wizard.css"; + * ... + * static get markup() { + * return` + * <template> + * <link rel="stylesheet" href=${migrationwizardStyles}> + * ... + * </template> + * `; + * } + */ + +const path = require("path"); +const projectRoot = path.join(process.cwd(), "../../.."); +const rewriteChromeUri = require("./chrome-uri-utils.js"); + +/** + * Return an array of the unique chrome:// CSS URIs referenced in this file. + * + * @param {string} source - The source file to scan. + * @returns {string[]} Unique list of chrome:// CSS URIs + */ +function getReferencedChromeUris(source) { + const chromeRegex = /chrome:\/\/.*?\.css/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 = rewriteChromeUri(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.addMissingDependency(path.join(projectRoot, localRelativePath)); + } + } + // Rewrite the source file with mapped chrome:// URIs. + let rewrittenSource = source; + for (let [chromeUri, localPath] of chromeUriToLocalPath.entries()) { + // Generate an import friendly variable name for the default export from + // the CSS file e.g. __chrome_styles_loader__moztoggleStyles. + let cssImport = `__chrome_styles_loader__${path + .basename(localPath, ".css") + .replaceAll("-", "")}Styles`; + + // MozTextLabel is a special case for now since we don't use a template. + if ( + this.resourcePath.endsWith("/moz-label.mjs") || + this.resourcePath.endsWith(".js") + ) { + rewrittenSource = rewrittenSource.replaceAll(`"${chromeUri}"`, cssImport); + } else { + rewrittenSource = rewrittenSource.replaceAll( + chromeUri, + `\$\{${cssImport}\}` + ); + } + + // Add a CSS import statement as the first line in the file. + rewrittenSource = + `import ${cssImport} from "${localPath}";\n` + rewrittenSource; + } + 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/chrome-uri-utils.js b/browser/components/storybook/.storybook/chrome-uri-utils.js new file mode 100644 index 0000000000..514d397961 --- /dev/null +++ b/browser/components/storybook/.storybook/chrome-uri-utils.js @@ -0,0 +1,30 @@ +/* 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 [prefixMap, aliasMap, sourceMap] = require("./chrome-map.js"); + +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 = rewriteChromeUri; diff --git a/browser/components/storybook/.storybook/fluent-utils.mjs b/browser/components/storybook/.storybook/fluent-utils.mjs new file mode 100644 index 0000000000..52a3721820 --- /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-fluent/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-fluent. +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..3e42f778a4 --- /dev/null +++ b/browser/components/storybook/.storybook/main.js @@ -0,0 +1,153 @@ +/* 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 rewriteChromeUri = require("./chrome-uri-utils.js"); + +const projectRoot = path.resolve(__dirname, "../../../../"); + +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)`, + // about:logins components stories + `${projectRoot}/browser/components/aboutlogins/content/components/**/*.stories.mjs`, + // Everything else + "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx|md)", + // Design system files + `${projectRoot}/toolkit/themes/shared/design-system/**/*.stories.@(js|jsx|mjs|ts|tsx|md)`, + ], + addons: [ + "@storybook/addon-links", + { + name: "@storybook/addon-essentials", + options: { + backgrounds: false, + measure: false, + outline: false, + }, + }, + "@storybook/addon-a11y", + path.resolve(__dirname, "addon-fluent"), + 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", + }); + + config.module.rules.push({ + test: /\.m?js$/, + exclude: /.storybook/, + use: [{ loader: path.resolve(__dirname, "./chrome-styles-loader.js") }], + }); + + // Replace the default CSS rule with a rule to emit a separate CSS file and + // export the URL. This allows us to rewrite the source to use CSS imports + // via the chrome-styles-loader. + let cssFileTest = /\.css$/.toString(); + let cssRuleIndex = config.module.rules.findIndex( + rule => rule.test.toString() === cssFileTest + ); + config.module.rules[cssRuleIndex] = { + test: /\.css$/, + exclude: [/.storybook/, /node_modules/], + type: "asset/resource", + generator: { + filename: "[name].[contenthash].css", + }, + }; + + // 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..b11036af74 --- /dev/null +++ b/browser/components/storybook/.storybook/markdown-story-loader.js @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +/* 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>\n$<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, "/"); + let componentName; + + if (relativePath.includes("toolkit/content/widgets")) { + let storyNameRegex = /(?<=\/widgets\/)(?<name>.*?)(?=\/)/g; + 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}`; + + let componentStories; + if (componentName) { + componentStories = this.resourcePath + .replace("README", componentName) + .replace(".md", ".mjs"); + try { + fs.statSync(componentStories); + componentStories = "./" + path.basename(componentStories); + } catch { + componentStories = null; + } + } + + // 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, Story } from "@storybook/addon-docs"; +${componentStories ? `import * as Stories from "${componentStories}";` : ""} + +<Meta + title="${title}" + ${componentStories ? `of={Stories}` : ""} + 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..c2f9e8d1a2 --- /dev/null +++ b/browser/components/storybook/.storybook/preview-head.html @@ -0,0 +1,208 @@ +<!-- 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-system/text-and-typography.css" +/> +<link + rel="stylesheet" + href="chrome://global/skin/design-system/tokens-brand.css" +/> + +<style> + :root { + font-size: var(--font-size-root); + } + + a { + color: var(--link-color) !important; + text-decoration: underline !important; + } + + /* 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; + } + + /* Typography preview and design tokens table */ + table.sb-preview-design-tokens { + -moz-osx-font-smoothing: auto; + border-collapse: separate; + table-layout: fixed; + text-align: left; + width: 100%; + + & h1.sb-preview-font-size-xxlarge { + font-size: var(--font-size-xxlarge); + } + + & th { + background: #ebf5fc; + } + + & tr td, + & tr th { + padding: 8px; + } + + & td { + background: #ffffff; + } + } + + td.sb-preview-chrome-typescale { + & .docs-story { + * { + font: message-box; + } + + & h1 { + font-weight: var(--font-weight-bold) !important; + } + } + + &.sb-preview-chrome-menu .docs-story * { + font: menu; + } + } + + h1.sb-preview-chrome-typescale { + font: message-box; + font-weight: var(--font-weight-bold) !important; + + &.sb-preview-chrome-menu { + font: menu; + } + } + + table .sb-preview-font-size-small { + font-size: var(--font-size-small); + } + + .box { + display: flex; + flex-wrap: wrap; + gap: 12px; + + &.width-max-content { + width: max-content; + } + + &.relative { + position: relative; + } + &.absolute { + position: absolute; + } + + &.justify-center { + justify-content: center; + } + + &.align-center { + align-items: center; + } + + &.align-end { + align-items: flex-end; + } + + &.vertical { + flex-direction: column; + } + + &.left { + left: 0; + } + + &.right { + right: 0; + } + + &.top { + top: 0; + } + + & + h1, + & + h2, + & + h3 { + margin-top: 16px !important; + } + } + + .post-it { + align-items: center; + background: linear-gradient( + to left bottom, + transparent 50%, + rgba(0, 0, 0, 0.4) 0 + ) + no-repeat 100% 0 / 1em 1em, + linear-gradient(-135deg, transparent 0.7em, var(--color-red-05) 0); + display: flex; + font-size: 14px !important; + height: 85px; + justify-content: center; + margin: 12px 0; + position: relative; + text-align: center; + width: 85px; + + &.green { + background: linear-gradient( + to left bottom, + transparent 50%, + rgba(0, 0, 0, 0.4) 0 + ) + no-repeat 100% 0 / 1em 1em, + linear-gradient(-135deg, transparent 0.7em, var(--color-green-05) 0); + } + + &.blue { + background: linear-gradient( + to left bottom, + transparent 50%, + rgba(0, 0, 0, 0.4) 0 + ) + no-repeat 100% 0 / 1em 1em, + linear-gradient(-135deg, transparent 0.7em, var(--color-blue-05) 0); + } + + &.orange { + background: linear-gradient( + to left bottom, + transparent 50%, + rgba(0, 0, 0, 0.4) 0 + ) + no-repeat 100% 0 / 1em 1em, + linear-gradient(-135deg, transparent 0.7em, var(--color-yellow-05) 0); + } + + &.big { + height: 160px; + width: 160px; + } + + &.disabled { + opacity: 0.4; + } + } + + .container-width-large { + min-width: 968px; + } +</style> diff --git a/browser/components/storybook/.storybook/preview.mjs b/browser/components/storybook/.storybook/preview.mjs new file mode 100644 index 0000000000..acff43b7c0 --- /dev/null +++ b/browser/components/storybook/.storybook/preview.mjs @@ -0,0 +1,108 @@ +/* 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, +}; + +// Used to set prefs in unprivileged contexts. +window.RPMSetPref = () => { + /* NOOP */ +}; +window.RPMGetFormatURLPref = () => { + /* NOOP */ +}; + +/** + * 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; + font-size: var(--font-size-root); + appearance: none; + background-color: var(--color-canvas); + color: var(--text-color); + -moz-box-layout: flex; + } + + :host { + font-size: var(--font-size-root); + } + `; + + static properties = { + story: { type: Function }, + context: { type: Object }, + }; + + connectedCallback() { + super.connectedCallback(); + this.classList.add("anonymous-content-host"); + } + + 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); |