diff --git a/client/src/document/index.tsx b/client/src/document/index.tsx index 1a395b7abb2e..58a4f125cba1 100644 --- a/client/src/document/index.tsx +++ b/client/src/document/index.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Suspense } from "react"; import { useNavigate } from "react-router-dom"; import useSWR, { mutate } from "swr"; @@ -9,7 +9,6 @@ import { useDocumentURL, useDecorateCodeExamples, useRunSample } from "./hooks"; import { Doc } from "../../../libs/types/document"; // Ingredients import { Prose } from "./ingredients/prose"; -import { LazyBrowserCompatibilityTable } from "./lazy-bcd-table"; import { SpecificationSection } from "./ingredients/spec-section"; // Misc @@ -43,9 +42,14 @@ import { BaselineIndicator } from "./baseline-indicator"; import { PlayQueue } from "../playground/queue"; import { useGleanClick } from "../telemetry/glean-context"; import { CLIENT_SIDE_NAVIGATION } from "../telemetry/constants"; +import { Spinner } from "../ui/atoms/spinner"; +import { DisplayH2, DisplayH3 } from "./ingredients/utils"; // import { useUIStatus } from "../ui-context"; // Lazy sub-components +const LazyCompatTable = React.lazy( + () => import("../lit/compat/lazy-compat-table.js") +); const Toolbar = React.lazy(() => import("./toolbar")); const MathMLPolyfillMaybe = React.lazy(() => import("./mathml-polyfill")); @@ -261,15 +265,19 @@ export function Document(props /* TODO: define a TS interface for this */) { } export function RenderDocumentBody({ doc }) { + const locale = useLocale(); + return doc.body.map((section, i) => { if (section.type === "prose") { return ; } else if (section.type === "browser_compatibility") { + const { id, title, isH3, query } = section.value; return ( - + } key={`browser_compatibility${i}`}> + {title && !isH3 && } + {title && isH3 && } + + ); } else if (section.type === "specifications") { return ( diff --git a/client/src/document/ingredients/browser-compatibility-table/browser-info.tsx b/client/src/document/ingredients/browser-compatibility-table/browser-info.tsx deleted file mode 100644 index e6f6390e77c9..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/browser-info.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React, { useContext } from "react"; -import type BCD from "@mdn/browser-compat-data/types"; - -export const BrowserInfoContext = React.createContext( - null -); - -export function BrowserName({ id }: { id: BCD.BrowserName }) { - const browserInfo = useContext(BrowserInfoContext); - if (!browserInfo) { - throw new Error("Missing browser info"); - } - return <>{browserInfo[id].name}; -} diff --git a/client/src/document/ingredients/browser-compatibility-table/error-boundary.test.tsx b/client/src/document/ingredients/browser-compatibility-table/error-boundary.test.tsx deleted file mode 100644 index c689dc6cab5a..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/error-boundary.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; - -import { BrowserCompatibilityErrorBoundary } from "./error-boundary"; - -function renderWithRouter(component) { - return render({component}); -} - -it("renders without crashing", () => { - const { container } = renderWithRouter( - -
- - ); - expect(container).toBeDefined(); -}); - -it("renders crashing mock component", () => { - function CrashingComponent() { - const [crashing, setCrashing] = React.useState(false); - - if (crashing) { - throw new Error("42"); - } - return ( -
{ - setCrashing(true); - }} - /> - ); - } - - const consoleError = jest - .spyOn(console, "error") - .mockImplementation(() => {}); - - const { container } = renderWithRouter( - - - - ); - expect(container.querySelector(".bc-table-error-boundary")).toBeNull(); - const div = container.querySelector("div"); - div && fireEvent.click(div); - - expect(consoleError).toHaveBeenCalledWith( - expect.stringMatching("The above error occurred") - ); - - // TODO: When `BrowserCompatibilityErrorBoundary` reports to Sentry, spy on the report function so that we can assert the error stack - expect(container.querySelector(".bc-table-error-boundary")).toBeDefined(); -}); diff --git a/client/src/document/ingredients/browser-compatibility-table/error-boundary.tsx b/client/src/document/ingredients/browser-compatibility-table/error-boundary.tsx deleted file mode 100644 index c149af562896..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/error-boundary.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; - -/** - * The error boundary for BrowserCompatibilityTable. - * - * When the whole BrowserCompatibilityTable crashes, for whatever reason, - * this component will show a friendly message - * to replace that crashed component - */ -export class BrowserCompatibilityErrorBoundary extends React.Component< - any, - any -> { - state = { - error: null, - }; - componentDidCatch(error, _errorInfo) { - this.setState({ - error, - }); - // TODO: Report this error to Sentry, https://github.com/mdn/yari/issues/99 - } - render() { - if (this.state.error) { - return ( - <> -
- Unfortunately, this table has encountered unhandled error and the - content cannot be shown. - {/* TODO: When error reporting is set up, the message should include "We have been notified of this error" or something similar */} -
- - ); - } - return this.props.children; - } -} diff --git a/client/src/document/ingredients/browser-compatibility-table/feature-row.tsx b/client/src/document/ingredients/browser-compatibility-table/feature-row.tsx deleted file mode 100644 index 0ed42fd36302..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/feature-row.tsx +++ /dev/null @@ -1,593 +0,0 @@ -import React, { useContext } from "react"; -import type BCD from "@mdn/browser-compat-data/types"; -import { BrowserInfoContext } from "./browser-info"; -import { - asList, - getCurrentSupport, - hasMore, - hasNoteworthyNotes, - isFullySupportedWithoutLimitation, - isNotSupportedAtAll, - isTruthy, - versionIsPreview, - SupportStatementExtended, - bugURLToString, -} from "./utils"; -import { LEGEND_LABELS } from "./legend"; -import { DEFAULT_LOCALE } from "../../../../../libs/constants"; -import { BCD_TABLE } from "../../../telemetry/constants"; - -function getSupportClassName( - support: SupportStatementExtended | undefined, - browser: BCD.BrowserStatement -): "no" | "yes" | "partial" | "preview" | "removed-partial" | "unknown" { - if (!support) { - return "unknown"; - } - - let { flags, version_added, version_removed, partial_implementation } = - getCurrentSupport(support)!; - - let className; - if (version_added === null) { - className = "unknown"; - } else if (versionIsPreview(version_added, browser)) { - className = "preview"; - } else if (version_added) { - className = "yes"; - if (version_removed || (flags && flags.length)) { - className = "no"; - } - } else { - className = "no"; - } - if (partial_implementation) { - className = version_removed ? "removed-partial" : "partial"; - } - - return className; -} - -function StatusIcons({ status }: { status: BCD.StatusBlock }) { - const icons = [ - status.experimental && { - title: "Experimental. Expect behavior to change in the future.", - text: "Experimental", - iconClassName: "icon-experimental", - }, - status.deprecated && { - title: "Deprecated. Not for use in new websites.", - text: "Deprecated", - iconClassName: "icon-deprecated", - }, - !status.standard_track && { - title: "Non-standard. Expect poor cross-browser support.", - text: "Non-standard", - iconClassName: "icon-nonstandard", - }, - ].filter(isTruthy); - return icons.length === 0 ? null : ( -
- {icons.map((icon) => ( - - {icon.text} - - ))} -
- ); -} - -function labelFromString( - version: string | boolean | null | undefined, - browser: BCD.BrowserStatement -) { - if (typeof version !== "string") { - return <>{"?"}; - } - // Treat BCD ranges as exact versions to avoid confusion for the reader - // See https://github.com/mdn/yari/issues/3238 - if (version.startsWith("≤")) { - return <>{version.slice(1)}; - } - if (version === "preview") { - return browser.preview_name; - } - return <>{version}; -} - -function versionLabelFromSupport( - added: string | boolean | null | undefined, - removed: string | boolean | null | undefined, - browser: BCD.BrowserStatement -) { - if (typeof removed !== "string") { - return <>{labelFromString(added, browser)}; - } - return ( - <> - {labelFromString(added, browser)} –  - {labelFromString(removed, browser)} - - ); -} - -const CellText = React.memo( - ({ - support, - browser, - timeline = false, - }: { - support: BCD.SupportStatement | undefined; - browser: BCD.BrowserStatement; - timeline?: boolean; - }) => { - const currentSupport = getCurrentSupport(support); - - const added = currentSupport?.version_added ?? null; - const lastVersion = currentSupport?.version_last ?? null; - - const browserReleaseDate = currentSupport?.release_date; - const supportClassName = getSupportClassName(support, browser); - - let status: - | { isSupported: "unknown" } - | { - isSupported: "no" | "yes" | "partial" | "preview" | "removed-partial"; - label?: React.ReactNode; - }; - switch (added) { - case null: - status = { isSupported: "unknown" }; - break; - case true: - status = { isSupported: lastVersion ? "no" : "yes" }; - break; - case false: - status = { isSupported: "no" }; - break; - case "preview": - status = { isSupported: "preview" }; - break; - default: - status = { - isSupported: supportClassName, - label: versionLabelFromSupport(added, lastVersion, browser), - }; - break; - } - - let label: string | React.ReactNode; - let title = ""; - switch (status.isSupported) { - case "yes": - title = "Full support"; - label = status.label || "Yes"; - break; - - case "partial": - title = "Partial support"; - label = status.label || "Partial"; - break; - - case "removed-partial": - if (timeline) { - title = "Partial support"; - label = status.label || "Partial"; - } else { - title = "No support"; - label = status.label || "No"; - } - break; - - case "no": - title = "No support"; - label = status.label || "No"; - break; - - case "preview": - title = "Preview support"; - label = status.label || browser.preview_name; - break; - - case "unknown": - title = "Support unknown"; - label = "?"; - break; - } - - title = `${browser.name} – ${title}`; - - return ( -
-
- - - {title} - - -
-
- {browser.name} - - {label} - {browserReleaseDate && timeline - ? ` (Released ${browserReleaseDate})` - : ""} - -
- -
- ); - } -); - -function Icon({ name }: { name: string }) { - const title = LEGEND_LABELS[name] ?? name; - - return ( - - {name} - - - ); -} - -function CellIcons({ support }: { support: BCD.SupportStatement | undefined }) { - const supportItem = getCurrentSupport(support); - if (!supportItem) { - return null; - } - - const icons = [ - supportItem.prefix && , - hasNoteworthyNotes(supportItem) && , - supportItem.alternative_name && , - supportItem.flags && , - hasMore(support) && , - ].filter(Boolean); - - return icons.length ?
{icons}
: null; -} - -function FlagsNote({ - supportItem, - browser, -}: { - supportItem: BCD.SimpleSupportStatement; - browser: BCD.BrowserStatement; -}) { - const hasAddedVersion = typeof supportItem.version_added === "string"; - const hasRemovedVersion = typeof supportItem.version_removed === "string"; - const flags = supportItem.flags || []; - return ( - <> - {hasAddedVersion && `From version ${supportItem.version_added}`} - {hasRemovedVersion && ( - <> - {hasAddedVersion ? " until" : "Until"} version{" "} - {supportItem.version_removed} (exclusive) - - )} - {hasAddedVersion || hasRemovedVersion ? ": this" : "This"} feature is - behind the{" "} - {flags.map((flag, i) => { - const valueToSet = flag.value_to_set && ( - <> - {" "} - (needs to be set to {flag.value_to_set}) - - ); - return ( - - {flag.name} - {flag.type === "preference" && <> preference{valueToSet}} - {flag.type === "runtime_flag" && <> runtime flag{valueToSet}} - {i < flags.length - 1 && " and the "} - - ); - })} - . - {browser.pref_url && - flags.some((flag) => flag.type === "preference") && - ` To change preferences in ${browser.name}, visit ${browser.pref_url}.`} - - ); -} - -function getNotes( - browser: BCD.BrowserStatement, - support: BCD.SupportStatement -) { - if (support) { - return asList(support) - .slice() - .reverse() - .flatMap((item, i) => { - const supportNotes = [ - item.version_removed && - !asList(support).some( - (otherItem) => otherItem.version_added === item.version_removed - ) - ? { - iconName: "footnote", - label: ( - <> - Removed in {labelFromString(item.version_removed, browser)}{" "} - and later - - ), - } - : null, - item.partial_implementation - ? { - iconName: "footnote", - label: "Partial support", - } - : null, - item.prefix - ? { - iconName: "prefix", - label: `Implemented with the vendor prefix: ${item.prefix}`, - } - : null, - item.alternative_name - ? { - iconName: "altname", - label: `Alternate name: ${item.alternative_name}`, - } - : null, - item.flags - ? { - iconName: "disabled", - label: , - } - : null, - item.notes - ? (Array.isArray(item.notes) ? item.notes : [item.notes]).map( - (note) => ({ iconName: "footnote", label: note }) - ) - : null, - item.impl_url - ? (Array.isArray(item.impl_url) - ? item.impl_url - : [item.impl_url] - ).map((impl_url) => ({ - iconName: "footnote", - label: ( - <> - See {bugURLToString(impl_url)}. - - ), - })) - : null, - versionIsPreview(item.version_added, browser) - ? { - iconName: "footnote", - label: "Preview browser support", - } - : null, - // If we encounter nothing else than the required `version_added` and - // `release_date` properties, assume full support. - // EDIT 1-5-21: if item.version_added doesn't exist, assume no support. - isFullySupportedWithoutLimitation(item) && - !versionIsPreview(item.version_added, browser) - ? { - iconName: "footnote", - label: "Full support", - } - : isNotSupportedAtAll(item) - ? { - iconName: "footnote", - label: "No support", - } - : null, - ] - .flat() - .filter(isTruthy); - - const hasNotes = supportNotes.length > 0; - return ( - (i === 0 || hasNotes) && ( - -
-
- -
- {supportNotes.map(({ iconName, label }, i) => { - return ( -
- {" "} - {typeof label === "string" ? ( - - ) : ( - label - )} -
- ); - })} - {!hasNotes &&
} -
-
- ) - ); - }) - .filter(isTruthy); - } -} - -function CompatCell({ - browserId, - browserInfo, - support, - showNotes, - onToggle, - locale, -}: { - browserId: BCD.BrowserName; - browserInfo: BCD.BrowserStatement; - support: BCD.SupportStatement | undefined; - showNotes: boolean; - onToggle: () => void; - locale: string; -}) { - const supportClassName = getSupportClassName(support, browserInfo); - // NOTE: 1-5-21, I've forced hasNotes to return true, in order to - // make the details view open all the time. - // Whenever the support statement is complex (array with more than one entry) - // or if a single entry is complex (prefix, notes, etc.), - // we need to render support details in `bc-history` - // const hasNotes = - // support && - // (asList(support).length > 1 || - // asList(support).some( - // (item) => - // item.prefix || item.notes || item.alternative_name || item.flags - // )); - const notes = getNotes(browserInfo, support!); - const content = ( - <> - - {showNotes && ( -
{notes}
- )} - - ); - - return ( - <> - onToggle() : undefined} - > - - - - ); -} - -export const FeatureRow = React.memo( - ({ - index, - feature, - browsers, - activeCell, - onToggleCell, - locale, - }: { - index: number; - feature: { - name: string; - compat: BCD.CompatStatement; - depth: number; - }; - browsers: BCD.BrowserName[]; - activeCell: number | null; - onToggleCell: ([row, column]: [number, number]) => void; - locale: string; - }) => { - const browserInfo = useContext(BrowserInfoContext); - - if (!browserInfo) { - throw new Error("Missing browser info"); - } - - const { name, compat, depth } = feature; - const title = compat.description ? ( - - ) : ( - {name} - ); - const activeBrowser = activeCell !== null ? browsers[activeCell] : null; - - let titleNode: string | React.ReactNode; - - if (compat.mdn_url && depth > 0) { - const href = compat.mdn_url.replace( - `/${DEFAULT_LOCALE}/docs`, - `/${locale}/docs` - ); - titleNode = ( - ${href}`} - > - {title} - {compat.status && } - - ); - } else { - titleNode = ( -
- {title} - {compat.status && } -
- ); - } - - return ( - <> - - - {titleNode} - - {browsers.map((browser, i) => ( - onToggleCell([index, i])} - locale={locale} - /> - ))} - - {activeBrowser && ( - - -
- {getNotes( - browserInfo[activeBrowser], - compat.support[activeBrowser]! - )} -
- - - )} - - ); - } -); diff --git a/client/src/document/ingredients/browser-compatibility-table/headers.tsx b/client/src/document/ingredients/browser-compatibility-table/headers.tsx deleted file mode 100644 index 5783a3984370..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/headers.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type BCD from "@mdn/browser-compat-data/types"; -import { BrowserName } from "./browser-info"; - -function PlatformHeaders({ - platforms, - browsers, - browserInfo, -}: { - platforms: string[]; - browsers: BCD.BrowserName[]; - browserInfo: BCD.Browsers; -}) { - return ( - - - {platforms.map((platform) => { - // Get the intersection of browsers in the `browsers` array and the - // `PLATFORM_BROWSERS[platform]`. - const browsersInPlatform = browsers.filter( - (browser) => browserInfo[browser].type === platform - ); - const browserCount = browsersInPlatform.length; - return ( - - - {platform} - - ); - })} - - ); -} - -function BrowserHeaders({ browsers }: { browsers: BCD.BrowserName[] }) { - return ( - - - {browsers.map((browser) => { - return ( - -
- -
-
- - ); - })} - - ); -} - -export function browserToIconName(browser: string) { - if (browser.startsWith("firefox")) { - return "simple-firefox"; - } else if (browser === "webview_android") { - return "webview"; - } else if (browser === "webview_ios") { - return "safari"; - } else { - const browserStart = browser.split("_")[0]; - return browserStart; - } -} - -export function Headers({ - platforms, - browsers, - browserInfo, -}: { - platforms: string[]; - browsers: BCD.BrowserName[]; - browserInfo: BCD.Browsers; -}) { - return ( - - - - - ); -} diff --git a/client/src/document/ingredients/browser-compatibility-table/index-desktop-md.scss b/client/src/document/ingredients/browser-compatibility-table/index-desktop-md.scss deleted file mode 100644 index 048daa0ca061..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/index-desktop-md.scss +++ /dev/null @@ -1,13 +0,0 @@ -@use "../../../ui/vars" as *; - -@media (min-width: $screen-md) { - .table-container { - width: calc(100% + 6rem); - } - - .bc-table { - tbody th { - width: 20%; - } - } -} diff --git a/client/src/document/ingredients/browser-compatibility-table/index-desktop-xl.scss b/client/src/document/ingredients/browser-compatibility-table/index-desktop-xl.scss deleted file mode 100644 index 901f96fe73e1..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/index-desktop-xl.scss +++ /dev/null @@ -1,12 +0,0 @@ -@use "../../../ui/vars" as *; - -@media (min-width: $screen-xl) { - .table-container { - margin: 0; - width: 100%; - } - - .table-container-inner { - padding: 0; - } -} diff --git a/client/src/document/ingredients/browser-compatibility-table/index-desktop.scss b/client/src/document/ingredients/browser-compatibility-table/index-desktop.scss deleted file mode 100644 index bfdec05111d1..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/index-desktop.scss +++ /dev/null @@ -1,99 +0,0 @@ -@use "../../../ui/vars" as *; - -// Style for desktop. - -@media (min-width: $screen-sm) { - .bc-table { - thead { - display: table-header-group; - - .bc-platforms { - th { - vertical-align: revert; - } - } - } - - td, - th { - background: inherit; - padding: 0.25rem; - width: 2rem; - } - - td.bc-support { - padding: 0; - - > button { - padding: 0.25rem; - } - } - - tr.bc-history-desktop { - display: table-row; - } - } - - .table-container { - margin: 0 -3rem; - overflow: auto; - width: 100vw; - } - - .table-container-inner { - min-width: max-content; - padding: 0 3rem; - position: relative; - - &:after { - bottom: 0; - content: ""; - height: 10px; - position: absolute; - right: 0; - width: 10px; - } - } - - .bc-support-level, - .bc-browser-name { - display: none; - } - - .bc-notes-list { - margin-left: 20%; - width: auto; - } - - .bc-support { - .bc-support-level { - display: none; - } - - &[aria-expanded="true"] { - position: relative; - - &:after { - background: var(--text-primary); - bottom: -1px; - content: ""; - height: 2px; - left: 0; - position: absolute; - width: 100%; - } - - .bc-history-mobile { - display: none; - } - } - } - - .bc-has-history { - cursor: pointer; - - &:hover { - background: var(--background-secondary); - } - } -} diff --git a/client/src/document/ingredients/browser-compatibility-table/index-mobile.scss b/client/src/document/ingredients/browser-compatibility-table/index-mobile.scss deleted file mode 100644 index e7da25d0789d..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/index-mobile.scss +++ /dev/null @@ -1,33 +0,0 @@ -@use "../../../ui/vars" as *; - -// Style for mobile. - -@media (max-width: $screen-sm - 1px) { - .bc-table { - thead { - display: none; - } - - td.bc-support { - border-left-width: 0; - display: block; - } - - .bc-feature, - .bc-support > button, - .bc-history > td { - align-content: center; - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - } - - .bc-history-desktop { - display: none; - } - } - - .table-container { - overflow-x: auto; - } -} diff --git a/client/src/document/ingredients/browser-compatibility-table/index.tsx b/client/src/document/ingredients/browser-compatibility-table/index.tsx deleted file mode 100644 index 3b7b23940c91..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/index.tsx +++ /dev/null @@ -1,329 +0,0 @@ -import React, { useReducer, useRef } from "react"; -import { useLocation } from "react-router-dom"; -import type BCD from "@mdn/browser-compat-data/types"; -import { BrowserInfoContext } from "./browser-info"; -import { BrowserCompatibilityErrorBoundary } from "./error-boundary"; -import { FeatureRow } from "./feature-row"; -import { Headers } from "./headers"; -import { Legend } from "./legend"; -import { - getCurrentSupport, - hasMore, - hasNoteworthyNotes, - listFeatures, - SupportStatementExtended, - versionIsPreview, -} from "./utils"; -import { useViewed } from "../../../hooks"; -import { BCD_TABLE } from "../../../telemetry/constants"; -import { useGleanClick } from "../../../telemetry/glean-context"; - -// Note! Don't import any SCSS here inside *this* component. -// It's done in the component that lazy-loads this component. - -// This string is used to prefill the body when clicking to file a new BCD -// issue over on github.com/mdn/browser-compat-data -const ISSUE_METADATA_TEMPLATE = ` - -
-MDN page report details - -* Query: \`$QUERY_ID\` -* Report started: $DATE - -
-`; - -export const HIDDEN_BROWSERS = ["ie"]; - -/** - * Return a list of platforms and browsers that are relevant for this category & - * data. - * - * If the category is "webextensions", only those are shown. In all other cases - * at least the entirety of the "desktop" and "mobile" platforms are shown. If - * the category is JavaScript, the entirety of the "server" category is also - * shown. In all other categories, if compat data has info about Deno / Node.js - * those are also shown. Deno is always shown if Node.js is shown. - */ -function gatherPlatformsAndBrowsers( - category: string, - data: BCD.Identifier, - browserInfo: BCD.Browsers -): [string[], BCD.BrowserName[]] { - const hasNodeJSData = data.__compat && "nodejs" in data.__compat.support; - const hasDenoData = data.__compat && "deno" in data.__compat.support; - - let platforms = ["desktop", "mobile"]; - if (category === "javascript" || hasNodeJSData || hasDenoData) { - platforms.push("server"); - } - - let browsers: BCD.BrowserName[] = []; - - // Add browsers in platform order to align table cells - for (const platform of platforms) { - browsers.push( - ...(Object.keys(browserInfo).filter( - (browser) => browserInfo[browser].type === platform - ) as BCD.BrowserName[]) - ); - } - - // Filter WebExtension browsers in corresponding tables. - if (category === "webextensions") { - browsers = browsers.filter( - (browser) => browserInfo[browser].accepts_webextensions - ); - } - - // If there is no Node.js data for a category outside of "javascript", don't - // show it. It ended up in the browser list because there is data for Deno. - if (category !== "javascript" && !hasNodeJSData) { - browsers = browsers.filter((browser) => browser !== "nodejs"); - } - - // Hide Internet Explorer compatibility data - browsers = browsers.filter((browser) => !HIDDEN_BROWSERS.includes(browser)); - - return [platforms, [...browsers]]; -} - -type CellIndex = [number, number]; - -function FeatureListAccordion({ - features, - browsers, - browserInfo, - locale, - query, -}: { - features: ReturnType; - browsers: BCD.BrowserName[]; - browserInfo: BCD.Browsers; - locale: string; - query: string; -}) { - const [[activeRow, activeColumn], dispatchCellToggle] = useReducer< - React.Reducer - >( - ([activeRow, activeColumn], [row, column]) => - activeRow === row && activeColumn === column - ? [null, null] - : [row, column], - [null, null] - ); - - const gleanClick = useGleanClick(); - const clickedCells = useRef(new Set()); - - return ( - <> - {features.map((feature, i) => ( - { - dispatchCellToggle([row, column]); - - const cell = `${column}:${row}`; - if (clickedCells.current.has(cell)) { - return; - } else { - clickedCells.current.add(cell); - } - - const feature = features[row]; - const browser = browsers[column]; - const support = feature.compat.support[browser]; - - function getCurrentSupportType( - support: SupportStatementExtended | undefined, - browser: BCD.BrowserStatement - ): - | "no" - | "yes" - | "partial" - | "preview" - | "removed" - | "removed-partial" - | "unknown" { - if (!support) { - return "unknown"; - } - - const currentSupport = getCurrentSupport(support)!; - - const { - flags, - version_added, - version_removed, - partial_implementation, - } = currentSupport; - - if (version_added === null) { - return "unknown"; - } else if (versionIsPreview(version_added, browser)) { - return "preview"; - } else if (version_added) { - if (version_removed) { - if (partial_implementation) { - return "removed-partial"; - } else { - return "removed"; - } - } else if (flags && flags.length) { - return "no"; - } else if (partial_implementation) { - return "partial"; - } else { - return "yes"; - } - } else { - return "no"; - } - } - - function getCurrentSupportAttributes( - support: SupportStatementExtended | undefined - ): string[] { - const supportItem = getCurrentSupport(support); - - if (!supportItem) { - return []; - } - - return [ - !!supportItem.prefix && "pre", - hasNoteworthyNotes(supportItem) && "note", - !!supportItem.alternative_name && "alt", - !!supportItem.flags && "flag", - hasMore(support) && "more", - ].filter((value) => typeof value === "string"); - } - - const supportType = getCurrentSupportType( - support, - browserInfo[browser] - ); - const attrs = getCurrentSupportAttributes(support); - - gleanClick( - `${BCD_TABLE}: click ${browser} ${query} -> ${feature.name} = ${supportType} [${attrs.join(",")}]` - ); - }} - locale={locale} - /> - ))} - - ); -} - -export default function BrowserCompatibilityTable({ - query, - data, - browsers: browserInfo, - locale, -}: { - query: string; - data: BCD.Identifier; - browsers: BCD.Browsers; - locale: string; -}) { - const location = useLocation(); - const gleanClick = useGleanClick(); - - const observedNode = useViewed( - () => { - gleanClick(`${BCD_TABLE}: view -> ${query}`); - }, - { - threshold: 0, - } - ); - - if (!data || !Object.keys(data).length) { - throw new Error( - "BrowserCompatibilityTable component called with empty data" - ); - } - - const breadcrumbs = query.split("."); - const category = breadcrumbs[0]; - const name = breadcrumbs[breadcrumbs.length - 1]; - - const [platforms, browsers] = gatherPlatformsAndBrowsers( - category, - data, - browserInfo - ); - - function getNewIssueURL() { - const url = "https://github.com/mdn/browser-compat-data/issues/new"; - const sp = new URLSearchParams(); - const metadata = ISSUE_METADATA_TEMPLATE.replace( - /\$DATE/g, - new Date().toISOString() - ) - .replace(/\$QUERY_ID/g, query) - .trim(); - sp.set("mdn-url", `https://developer.mozilla.org${location.pathname}`); - sp.set("metadata", metadata); - sp.set("title", `${query} - `); - sp.set("template", "data-problem.yml"); - return `${url}?${sp.toString()}`; - } - - return ( - - - - Report problems with this compatibility data on GitHub - -
-
- - - - - -
-
-
- - - {/* https://github.com/mdn/yari/issues/1191 */} -
- The compatibility table on this page is generated from structured - data. If you'd like to contribute to the data, please check out{" "} - - https://github.com/mdn/browser-compat-data - {" "} - and send us a pull request. -
-
-
- ); -} diff --git a/client/src/document/ingredients/browser-compatibility-table/legend.tsx b/client/src/document/ingredients/browser-compatibility-table/legend.tsx deleted file mode 100644 index e59398f91e8a..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/legend.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useContext } from "react"; -import type BCD from "@mdn/browser-compat-data/types"; -import { BrowserInfoContext } from "./browser-info"; -import { HIDDEN_BROWSERS } from "./index"; -import { - asList, - getFirst, - hasMore, - hasNoteworthyNotes, - listFeatures, - versionIsPreview, -} from "./utils"; - -// Also specifies the order in which the legend appears -export const LEGEND_LABELS = { - yes: "Full support", - partial: "Partial support", - preview: "In development. Supported in a pre-release version.", - no: "No support", - unknown: "Compatibility unknown", - experimental: "Experimental. Expect behavior to change in the future.", - nonstandard: "Non-standard. Check cross-browser support before using.", - deprecated: "Deprecated. Not for use in new websites.", - footnote: "See implementation notes.", - disabled: "User must explicitly enable this feature.", - altname: "Uses a non-standard name.", - prefix: "Requires a vendor prefix or different name for use.", - more: "Has more compatibility info.", -}; -type LEGEND_KEY = keyof typeof LEGEND_LABELS; - -function getActiveLegendItems( - compat: BCD.Identifier, - name: string, - browserInfo: BCD.Browsers -) { - const legendItems = new Set(); - - for (const feature of listFeatures(compat, "", name)) { - const { status } = feature.compat; - - if (status) { - if (status.experimental) { - legendItems.add("experimental"); - } - if (status.deprecated) { - legendItems.add("deprecated"); - } - if (!status.standard_track) { - legendItems.add("nonstandard"); - } - } - - for (const [browser, browserSupport] of Object.entries( - feature.compat.support - )) { - if (HIDDEN_BROWSERS.includes(browser)) { - continue; - } - if (!browserSupport) { - legendItems.add("no"); - continue; - } - const firstSupportItem = getFirst(browserSupport); - if (hasNoteworthyNotes(firstSupportItem)) { - legendItems.add("footnote"); - } - - for (const versionSupport of asList(browserSupport)) { - if (versionSupport.version_added) { - if (versionSupport.flags && versionSupport.flags.length) { - legendItems.add("no"); - } else if ( - versionIsPreview(versionSupport.version_added, browserInfo[browser]) - ) { - legendItems.add("preview"); - } else { - legendItems.add("yes"); - } - } else if (versionSupport.version_added == null) { - legendItems.add("unknown"); - } else { - legendItems.add("no"); - } - - if (versionSupport.partial_implementation) { - legendItems.add("partial"); - } - if (versionSupport.prefix) { - legendItems.add("prefix"); - } - if (versionSupport.alternative_name) { - legendItems.add("altname"); - } - if (versionSupport.flags) { - legendItems.add("disabled"); - } - } - - if (hasMore(browserSupport)) { - legendItems.add("more"); - } - } - } - return Object.keys(LEGEND_LABELS) - .filter((key) => legendItems.has(key as LEGEND_KEY)) - .map((key) => [key, LEGEND_LABELS[key]]); -} - -export function Legend({ - compat, - name, -}: { - compat: BCD.Identifier; - name: string; -}) { - const browserInfo = useContext(BrowserInfoContext); - - if (!browserInfo) { - throw new Error("Missing browser info"); - } - - return ( -
-

- Legend -

-

- Tip: you can click/tap on a cell for more information. -

-
- {getActiveLegendItems(compat, name, browserInfo).map(([key, label]) => - ["yes", "partial", "no", "unknown", "preview"].includes(key) ? ( -
-
- - - {label} - - -
-
{label}
-
- ) : ( -
-
- -
-
{label}
-
- ) - )} -
-
- ); -} diff --git a/client/src/document/ingredients/browser-compatibility-table/utils.ts b/client/src/document/ingredients/browser-compatibility-table/utils.ts deleted file mode 100644 index ceb307c925b2..000000000000 --- a/client/src/document/ingredients/browser-compatibility-table/utils.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type BCD from "@mdn/browser-compat-data/types"; - -// Extended for the fields, beyond the bcd types, that are extra-added -// exclusively in Yari. -export interface SimpleSupportStatementExtended - extends BCD.SimpleSupportStatement { - // Known for some support statements where the browser *version* is known, - // as opposed to just "true" and if the version release date is known. - release_date?: string; - // The version before the version_removed if the *version* removed is known, - // as opposed to just "true". Otherwise the version_removed. - version_last?: BCD.VersionValue; -} - -export type SupportStatementExtended = - | SimpleSupportStatementExtended - | SimpleSupportStatementExtended[]; - -export function getFirst(a: T | T[]): T; -export function getFirst(a: T | T[] | undefined): T | undefined { - return Array.isArray(a) ? a[0] : a; -} - -export function asList(a: T | T[]): T[] { - return Array.isArray(a) ? a : [a]; -} - -export function isTruthy(t: T | false | undefined | null): t is T { - return Boolean(t); -} - -interface Feature { - name: string; - compat: BCD.CompatStatement; - depth: number; -} - -function findFirstCompatDepth(identifier: BCD.Identifier) { - const entries = [["", identifier]]; - - while (entries.length) { - const [path, value] = entries.shift() as [string, BCD.Identifier]; - if (value.__compat) { - // Following entries have at least this depth. - return path.split(".").length; - } - - for (const key of Object.keys(value)) { - const subpath = path ? `${path}.${key}` : key; - entries.push([subpath, value[key]]); - } - } - - // Fallback. - return 0; -} - -export function listFeatures( - identifier: BCD.Identifier, - parentName: string = "", - rootName: string = "", - depth: number = 0, - firstCompatDepth: number = 0 -): Feature[] { - const features: Feature[] = []; - if (rootName && identifier.__compat) { - features.push({ - name: rootName, - compat: identifier.__compat, - depth, - }); - } - if (rootName) { - firstCompatDepth = findFirstCompatDepth(identifier); - } - for (const subName of Object.keys(identifier)) { - if (subName === "__compat") { - continue; - } - const subIdentifier = identifier[subName]; - if (subIdentifier.__compat) { - features.push({ - name: parentName ? `${parentName}.${subName}` : subName, - compat: subIdentifier.__compat, - depth: depth + 1, - }); - } - if (subIdentifier.__compat || depth + 1 < firstCompatDepth) { - features.push( - ...listFeatures(subIdentifier, subName, "", depth + 1, firstCompatDepth) - ); - } - } - return features; -} - -export function hasMore(support: BCD.SupportStatement | undefined) { - return Array.isArray(support) && support.length > 1; -} - -export function versionIsPreview( - version: BCD.VersionValue | string | undefined, - browser: BCD.BrowserStatement -): boolean { - if (version === "preview") { - return true; - } - - if (browser && typeof version === "string" && browser.releases[version]) { - return ["beta", "nightly", "planned"].includes( - browser.releases[version].status - ); - } - - return false; -} - -export function hasNoteworthyNotes(support: BCD.SimpleSupportStatement) { - return ( - !!(support.notes?.length || support.impl_url?.length) && - !support.version_removed && - !support.partial_implementation - ); -} - -export function bugURLToString(url: string) { - const bugNumber = url.match( - /^https:\/\/(?:crbug\.com|webkit\.org\/b|bugzil\.la)\/([0-9]+)/i - )?.[1]; - return bugNumber ? `bug ${bugNumber}` : url; -} - -function hasLimitation(support: BCD.SimpleSupportStatement) { - return hasMajorLimitation(support) || support.notes || support.impl_url; -} - -function hasMajorLimitation(support: BCD.SimpleSupportStatement) { - return ( - support.partial_implementation || - support.alternative_name || - support.flags || - support.prefix || - support.version_removed - ); -} -export function isFullySupportedWithoutLimitation( - support: BCD.SimpleSupportStatement -) { - return support.version_added && !hasLimitation(support); -} - -export function isNotSupportedAtAll(support: BCD.SimpleSupportStatement) { - return !support.version_added && !hasLimitation(support); -} - -function isFullySupportedWithoutMajorLimitation( - support: BCD.SimpleSupportStatement -) { - return support.version_added && !hasMajorLimitation(support); -} - -// Prioritizes support items -export function getCurrentSupport( - support: SupportStatementExtended | undefined -): SimpleSupportStatementExtended | undefined { - if (!support) return undefined; - - // Full support without limitation - const noLimitationSupportItem = asList(support).find((item) => - isFullySupportedWithoutLimitation(item) - ); - if (noLimitationSupportItem) return noLimitationSupportItem; - - // Full support with only notes and version_added - const minorLimitationSupportItem = asList(support).find((item) => - isFullySupportedWithoutMajorLimitation(item) - ); - if (minorLimitationSupportItem) return minorLimitationSupportItem; - - // Full support with altname/prefix - const altnamePrefixSupportItem = asList(support).find( - (item) => !item.version_removed && (item.prefix || item.alternative_name) - ); - if (altnamePrefixSupportItem) return altnamePrefixSupportItem; - - // Partial support - const partialSupportItem = asList(support).find( - (item) => !item.version_removed && item.partial_implementation - ); - if (partialSupportItem) return partialSupportItem; - - // Support with flags only - const flagSupportItem = asList(support).find( - (item) => !item.version_removed && item.flags - ); - if (flagSupportItem) return flagSupportItem; - - // No/Inactive support - const noSupportItem = asList(support).find((item) => item.version_removed); - if (noSupportItem) return noSupportItem; - - // Default (likely never reached) - return getFirst(support); -} diff --git a/client/src/document/lazy-bcd-table.tsx b/client/src/document/lazy-bcd-table.tsx deleted file mode 100644 index 067f201c0b0a..000000000000 --- a/client/src/document/lazy-bcd-table.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import React, { lazy, Suspense } from "react"; -import useSWR from "swr"; - -import { DisplayH2, DisplayH3 } from "./ingredients/utils"; -import { Loading } from "../ui/atoms/loading"; -// Because it's bad for web performance to lazy-load CSS during the initial render -// (because the page is saying "Wait! Stop rendering, now that I've downloaded -// some JS I decided I need more CSSOM to block the rendering.") -// Therefore, we import all the necessary CSS here in this file so that -// the BCD table CSS becomes part of the core bundle. -// That means that when the lazy-loading happens, it only needs to lazy-load -// the JS (and the JSON XHR fetch of course) -import "./ingredients/browser-compatibility-table/index.scss"; -import { useLocale, useIsServer } from "../hooks"; -import NoteCard from "../ui/molecules/notecards"; -import type BCD from "@mdn/browser-compat-data/types"; -import { BCD_BASE_URL } from "../env"; - -interface QueryJson { - query: string; - data: BCD.Identifier; - browsers: BCD.Browsers; -} - -const BrowserCompatibilityTable = lazy( - () => - import( - /* webpackChunkName: "browser-compatibility-table" */ "./ingredients/browser-compatibility-table" - ) -); - -export function LazyBrowserCompatibilityTable({ - id, - title, - isH3, - query, -}: { - id: string; - title: string; - isH3: boolean; - query: string; -}) { - return ( - <> - {title && !isH3 && } - {title && isH3 && } - - - ); -} - -function LazyBrowserCompatibilityTableInner({ query }: { query: string }) { - const locale = useLocale(); - const isServer = useIsServer(); - - const { error, data } = useSWR( - query, - async (query) => { - const response = await fetch( - `${BCD_BASE_URL}/bcd/api/v0/current/${query}.json` - ); - if (!response.ok) { - throw new Error(response.status.toString()); - } - return (await response.json()) as QueryJson; - }, - { revalidateOnFocus: false } - ); - - if (isServer) { - return ( -

- BCD tables only load in the browser - -

- ); - } - if (error) { - if (error.message === "404") { - return ( - -

- No compatibility data found for {query}.
- Check for problems with this page or - contribute missing data to{" "} - - mdn/browser-compat-data - - . -

-
- ); - } - return

Error loading BCD data

; - } - if (!data) { - return ; - } - - return ( - - }> - - - - ); -} - -type ErrorBoundaryProps = { children?: React.ReactNode }; -type ErrorBoundaryState = { - error: Error | null; -}; - -class ErrorBoundary extends React.Component< - ErrorBoundaryProps, - ErrorBoundaryState -> { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { error: null }; - } - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - // componentDidCatch(error: Error, errorInfo) { - // console.log({ error, errorInfo }); - // } - - render() { - if (this.state.error) { - return ( - -

- Error loading browser compatibility table -

-

- This can happen if the JavaScript, which is loaded later, didn't - successfully load. -

-

- { - event.preventDefault(); - window.location.reload(); - }} - > - Try reloading the page - -

-
-

- If you're curious, this was the error: -
- - {this.state.error.toString()} - -

-
- ); - } - - return this.props.children; - } -} diff --git a/client/src/lit/compat/compat-table.js b/client/src/lit/compat/compat-table.js new file mode 100644 index 000000000000..f4886f8e59e8 --- /dev/null +++ b/client/src/lit/compat/compat-table.js @@ -0,0 +1,816 @@ +import { html, LitElement } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; + +import { getActiveLegendItems } from "./legend.js"; +import { + asList, + bugURLToString, + getCurrentSupport, + hasMore, + hasNoteworthyNotes, + HIDDEN_BROWSERS, + isFullySupportedWithoutLimitation, + isNotSupportedAtAll, + listFeatures, + versionIsPreview, +} from "./utils.js"; + +import styles from "./index.scss?css" with { type: "css" }; +import { DEFAULT_LOCALE } from "../../../../libs/constants/index.js"; +import { BCD_TABLE } from "../../telemetry/constants.ts"; +import { + getSupportBrowserReleaseDate, + getSupportClassName, + labelFromString, + versionLabelFromSupport, +} from "./feature-row.js"; + +/** + * @import { TemplateResult } from "lit" + * @import { BrowserName, BrowserStatement, SupportStatement, Browsers, Identifier, StatusBlock } from "@mdn/browser-compat-data/types" + * @typedef {{ title: string, text: string, iconClassName: string }} StatusIcon + */ + +const ISSUE_METADATA_TEMPLATE = ` + +
+MDN page report details + +* Query: \`$QUERY_ID\` +* Report started: $DATE + +
+`; + +/** + * @param {BrowserName} browser + * @returns {string} + */ +function browserToIconName(browser) { + if (browser.startsWith("firefox")) { + return "simple-firefox"; + } else if (browser === "webview_android") { + return "webview"; + } else if (browser === "webview_ios") { + return "safari"; + } else { + return browser.split("_")[0] ?? ""; + } +} + +// Also specifies the order in which the legend +/** + * @type {Record} + */ +export const LEGEND_LABELS = { + yes: "Full support", + partial: "Partial support", + preview: "In development. Supported in a pre-release version.", + no: "No support", + unknown: "Compatibility unknown", + experimental: "Experimental. Expect behavior to change in the future.", + nonstandard: "Non-standard. Check cross-browser support before using.", + deprecated: "Deprecated. Not for use in new websites.", + footnote: "See implementation notes.", + disabled: "User must explicitly enable this feature.", + altname: "Uses a non-standard name.", + prefix: "Requires a vendor prefix or different name for use.", + more: "Has more compatibility info.", +}; + +class CompatTable extends LitElement { + static properties = { + query: {}, + locale: {}, + data: {}, + browserInfo: { attribute: "browserinfo" }, + _pathname: { state: true }, + _platforms: { state: true }, + _browsers: { state: true }, + }; + + static styles = styles; + + constructor() { + super(); + this.query = ""; + /** @type {Identifier} */ + this.data = {}; + /** @type {Browsers} */ + // @ts-ignore + this.browserInfo = {}; + this.locale = ""; + this._pathname = ""; + /** @type {string[]} */ + this._platforms = []; + /** @type {BrowserName[]} */ + this._browsers = []; + } + + get _breadcrumbs() { + return this.query.split("."); + } + + get _category() { + return this._breadcrumbs[0] ?? ""; + } + + get _name() { + return this._breadcrumbs.at(-1) ?? ""; + } + + connectedCallback() { + super.connectedCallback(); + this._pathname = window.location.pathname; + [this._platforms, this._browsers] = gatherPlatformsAndBrowsers( + this._category, + this.data, + this.browserInfo + ); + } + + get _issueUrl() { + const url = "https://github.com/mdn/browser-compat-data/issues/new"; + const sp = new URLSearchParams(); + const metadata = ISSUE_METADATA_TEMPLATE.replace( + /\$DATE/g, + new Date().toISOString() + ) + .replace(/\$QUERY_ID/g, this.query) + .trim(); + sp.set("mdn-url", `https://developer.mozilla.org${this._pathname}`); + sp.set("metadata", metadata); + sp.set("title", `${this.query} - `); + sp.set("template", "data-problem.yml"); + + return `${url}?${sp.toString()}`; + } + + _renderIssueLink() { + const onClick = (/** @type {MouseEvent} */ event) => { + event.preventDefault(); + window.open(this._issueUrl, "_blank", "noopener,noreferrer"); + }; + const source_file = this.data.__compat?.source_file; + return html``; + } + + _renderTable() { + return html`
+
+ ${this._renderIssueLink()} + + ${this._renderTableHeader()} ${this._renderTableBody()} +
+
+
`; + } + + _renderTableHeader() { + return html` + ${this._renderPlatformHeaders()} ${this._renderBrowserHeaders()} + `; + } + + _renderPlatformHeaders() { + const platformsWithBrowsers = this._platforms.map((platform) => ({ + platform, + browsers: this._browsers.filter( + (browser) => this.browserInfo[browser].type === platform + ), + })); + + const grid = platformsWithBrowsers.map(({ browsers }) => browsers.length); + return html` + + ${platformsWithBrowsers.map(({ platform, browsers }, index) => { + // Get the intersection of browsers in the `browsers` array and the + // `PLATFORM_BROWSERS[platform]`. + const browserCount = browsers.length; + const cellClass = `bc-platform bc-platform-${platform}`; + const iconClass = `icon icon-${platform}`; + + const columnStart = + 2 + grid.slice(0, index).reduce((acc, x) => acc + x, 0); + const columnEnd = columnStart + browserCount; + return html` + + ${platform} + `; + })} + `; + } + + _renderBrowserHeaders() { + // + return html` + + ${this._browsers.map( + (browser) => + html` +
+ ${this.browserInfo[browser]?.name} +
+
+ ` + )} + `; + } + + _renderTableBody() { + // + const { data, _browsers: browsers, browserInfo, locale } = this; + const features = listFeatures(data, "", this._name); + + return html` + ${features.map((feature) => { + // + const { name, compat, depth } = feature; + + const title = compat.description + ? html`${unsafeHTML(compat.description)}` + : html`${name}`; + + let titleNode; + const titleContent = html`${title}${compat.status && + this._renderStatusIcons(compat.status)}`; + if (compat.mdn_url && depth > 0) { + const href = compat.mdn_url.replace( + `/${DEFAULT_LOCALE}/docs`, + `/${locale}/docs` + ); + titleNode = html` ${href}`} + > + ${titleContent} + `; + } else { + titleNode = html`
+ ${titleContent} +
`; + } + + const handleMousedown = (/** @type {MouseEvent} */ event) => { + // Blur active element if already focused. + const activeElement = this.shadowRoot?.activeElement; + const { currentTarget } = event; + + if ( + activeElement instanceof HTMLElement && + currentTarget instanceof HTMLElement + ) { + const activeCell = activeElement.closest("td"); + const currentCell = currentTarget.closest("td"); + + if (activeCell === currentCell) { + activeElement.blur(); + event.preventDefault(); + return; + } + } + + if (currentTarget instanceof HTMLElement) { + // Workaround for Safari, which doesn't focus implicitly. + setTimeout(() => currentTarget.focus(), 0); + } + }; + + return html` + + ${titleNode} + + ${browsers.map((browserName) => { + // + const browser = browserInfo[browserName]; + const support = compat.support[browserName] ?? { + version_added: null, + }; + + const supportClassName = getSupportClassName(support, browser); + const notes = this._renderNotes(browser, support); + + return html` + + ${notes && + html`
+
${notes}
+
`} + `; + })} + `; + })} + `; + } + + /** + * @param {SupportStatement} support + */ + _renderCellIcons(support) { + const supportItem = getCurrentSupport(support); + if (!supportItem) { + return null; + } + + const icons = [ + supportItem.prefix && this._renderIcon("prefix"), + hasNoteworthyNotes(supportItem) && this._renderIcon("footnote"), + supportItem.alternative_name && this._renderIcon("altname"), + supportItem.flags && this._renderIcon("disabled"), + hasMore(support) && this._renderIcon("more"), + ].filter(Boolean); + + return icons.length ? html`
${icons}
` : null; + } + + /** + * @param {string} name + * @returns {TemplateResult} + */ + _renderIcon(name) { + const title = name in LEGEND_LABELS ? LEGEND_LABELS[name] : name; + + return html` + ${name} + + `; + } + + /** + * @param {StatusBlock} status + */ + _renderStatusIcons(status) { + // + /** + * @type {StatusIcon[]} + */ + const icons = []; + if (status.experimental) { + icons.push({ + title: "Experimental. Expect behavior to change in the future.", + text: "Experimental", + iconClassName: "icon-experimental", + }); + } + + if (status.deprecated) { + icons.push({ + title: "Deprecated. Not for use in new websites.", + text: "Deprecated", + iconClassName: "icon-deprecated", + }); + } + + if (!status.standard_track) { + icons.push({ + title: "Non-standard. Expect poor cross-browser support.", + text: "Non-standard", + iconClassName: "icon-nonstandard", + }); + } + + return icons.length === 0 + ? null + : html`
+ ${icons.map( + (icon) => + html` + ${icon.text} + ` + )} +
`; + } + + /** + * + * @param {BrowserStatement} browser + * @param {SupportStatement} support + */ + _renderNotes(browser, support) { + return asList(support) + .slice() + .reverse() + .flatMap((item, i) => { + /** + * @type {Array<{iconName: string; label: string | TemplateResult } | null>} + */ + const supportNotes = [ + item.version_removed && + !asList(support).some( + (otherItem) => otherItem.version_added === item.version_removed + ) + ? { + iconName: "footnote", + label: `Removed in ${labelFromString(item.version_removed, browser)} and later`, + } + : null, + item.partial_implementation + ? { + iconName: "footnote", + label: "Partial support", + } + : null, + item.prefix + ? { + iconName: "prefix", + label: `Implemented with the vendor prefix: ${item.prefix}`, + } + : null, + item.alternative_name + ? { + iconName: "altname", + label: `Alternate name: ${item.alternative_name}`, + } + : null, + item.flags + ? { + iconName: "disabled", + label: (() => { + const hasAddedVersion = + typeof item.version_added === "string"; + const hasRemovedVersion = + typeof item.version_removed === "string"; + const flags = item.flags || []; + return html` + ${[ + hasAddedVersion && `From version ${item.version_added}`, + hasRemovedVersion && + `${hasAddedVersion ? " until" : "Until"} ${item.version_removed} (exclusive)`, + hasAddedVersion || hasRemovedVersion ? ": this" : "This", + " feature is behind the", + ...flags.map((flag, i) => { + const valueToSet = flag.value_to_set + ? html` (needs to be set to + ${flag.value_to_set})` + : ""; + + return [ + html`${flag.name}`, + flag.type === "preference" && + html` preference${valueToSet}`, + flag.type === "runtime_flag" && + html` runtime flag${valueToSet}`, + i < flags.length - 1 && " and the ", + ].filter(Boolean); + }), + ".", + browser.pref_url && + flags.some((flag) => flag.type === "preference") && + ` To change preferences in ${browser.name}, visit ${browser.pref_url}.`, + ] + .filter(Boolean) + .map((value) => html`${value}`)} + `; + })(), + } + : null, + item.notes + ? (Array.isArray(item.notes) ? item.notes : [item.notes]).map( + (note) => ({ iconName: "footnote", label: note }) + ) + : null, + item.impl_url + ? (Array.isArray(item.impl_url) + ? item.impl_url + : [item.impl_url] + ).map((impl_url) => ({ + iconName: "footnote", + label: html`See + ${bugURLToString(impl_url)}.`, + })) + : null, + versionIsPreview(item.version_added, browser) + ? { + iconName: "footnote", + label: "Preview browser support", + } + : null, + // If we encounter nothing else than the required `version_added` and + // `release_date` properties, assume full support. + // EDIT 1-5-21: if item.version_added doesn't exist, assume no support. + isFullySupportedWithoutLimitation(item) && + !versionIsPreview(item.version_added, browser) + ? { + iconName: "footnote", + label: "Full support", + } + : isNotSupportedAtAll(item) + ? { + iconName: "footnote", + label: "No support", + } + : { + iconName: "unknown", + label: "Support unknown", + }, + ].flat(); + + /** + * @type {Array<{iconName: string; label: string | TemplateResult }>} + */ + const filteredSupportNotes = supportNotes.filter((v) => v !== null); + + const hasNotes = supportNotes.length > 0; + return ( + (i === 0 || hasNotes) && + html`
+
+ ${this._renderCellText(item, browser, true)} +
+ ${filteredSupportNotes.map(({ iconName, label }) => { + return html`
+ ${this._renderIcon(iconName)}${typeof label === "string" + ? html`${unsafeHTML(label)}` + : label} +
`; + })} + ${!hasNotes ? html`
` : null} +
` + ); + }) + .filter(Boolean); + } + + /** + * + * @param {SupportStatement | undefined} support + * @param {BrowserStatement} browser + * @param {boolean} [timeline] + */ + _renderCellText(support, browser, timeline = false) { + const currentSupport = getCurrentSupport(support); + + const added = currentSupport?.version_added ?? null; + const lastVersion = currentSupport?.version_last ?? null; + + const browserReleaseDate = getSupportBrowserReleaseDate(support); + const supportClassName = getSupportClassName(support, browser); + + let status; + switch (added) { + case null: + status = { isSupported: "unknown" }; + break; + case true: + status = { isSupported: lastVersion ? "no" : "yes" }; + break; + case false: + status = { isSupported: "no" }; + break; + case "preview": + status = { isSupported: "preview" }; + break; + default: + status = { + isSupported: supportClassName, + label: versionLabelFromSupport(added, lastVersion, browser), + }; + break; + } + + let label; + let title = ""; + // eslint-disable-next-line default-case + switch (status.isSupported) { + case "yes": + title = "Full support"; + label = status.label || "Yes"; + break; + + case "partial": + title = "Partial support"; + label = status.label || "Partial"; + break; + + case "removed-partial": + if (timeline) { + title = "Partial support"; + label = status.label || "Partial"; + } else { + title = "No support"; + label = status.label || "No"; + } + break; + + case "no": + title = "No support"; + label = status.label || "No"; + break; + + case "preview": + title = "Preview support"; + label = status.label || browser.preview_name; + break; + + case "unknown": + title = "Support unknown"; + label = "?"; + break; + } + + title = `${browser.name} – ${title}`; + + return html`
+
+ + + ${title} + + +
+
+ ${browser.name} + + ${!timeline || added ? label : null} + ${browserReleaseDate && timeline + ? ` (Released ${browserReleaseDate})` + : ""} + +
+ ${support && this._renderCellIcons(support)} +
`; + } + + _renderTableLegend() { + const { _browsers: browsers, browserInfo } = this; + + if (!browserInfo) { + throw new Error("Missing browser info"); + } + + return html`
+

Legend

+

+ Tip: you can click/tap on a cell for more information. +

+
+ ${getActiveLegendItems( + this.data, + this._name, + browserInfo, + browsers + ).map(([key, label]) => + ["yes", "partial", "no", "unknown", "preview"].includes(key) + ? html`
+
+ + + ${label} + + +
+
${label}
+
` + : html`
+
+ +
+
${label}
+
` + )} +
+
`; + } + + render() { + return html` ${this._renderTable()} ${this._renderTableLegend()} `; + } +} + +customElements.define("compat-table", CompatTable); + +/** + * Return a list of platforms and browsers that are relevant for this category & + * data. + * + * If the category is "webextensions", only those are shown. In all other cases + * at least the entirety of the "desktop" and "mobile" platforms are shown. If + * the category is JavaScript, the entirety of the "server" category is also + * shown. In all other categories, if compat data has info about Deno / Node.js + * those are also shown. Deno is always shown if Node.js is shown. + * + * @param {string} category + * @param {Identifier} data + * @param {Browsers} browserInfo + * @returns {[string[], BrowserName[]]} + */ +export function gatherPlatformsAndBrowsers(category, data, browserInfo) { + const hasNodeJSData = data.__compat && "nodejs" in data.__compat.support; + const hasDenoData = data.__compat && "deno" in data.__compat.support; + + let platforms = ["desktop", "mobile"]; + if (category === "javascript" || hasNodeJSData || hasDenoData) { + platforms.push("server"); + } + + /** @type BrowserName[] */ + let browsers = []; + + // Add browsers in platform order to align table cells + for (const platform of platforms) { + /** + * @type {BrowserName[]} + */ + // @ts-ignore + const platformBrowsers = Object.keys(browserInfo); + browsers.push( + ...platformBrowsers.filter( + (browser) => + browser in browserInfo && browserInfo[browser].type === platform + ) + ); + } + + // Filter WebExtension browsers in corresponding tables. + if (category === "webextensions") { + browsers = browsers.filter( + (browser) => browserInfo[browser].accepts_webextensions + ); + } + + // If there is no Node.js data for a category outside "javascript", don't + // show it. It ended up in the browser list because there is data for Deno. + if (category !== "javascript" && !hasNodeJSData) { + browsers = browsers.filter((browser) => browser !== "nodejs"); + } + + // Hide Internet Explorer compatibility data + browsers = browsers.filter((browser) => !HIDDEN_BROWSERS.includes(browser)); + + return [platforms, [...browsers]]; +} diff --git a/client/src/lit/compat/feature-row.js b/client/src/lit/compat/feature-row.js new file mode 100644 index 000000000000..9850479d27ed --- /dev/null +++ b/client/src/lit/compat/feature-row.js @@ -0,0 +1,105 @@ +import { getCurrentSupport, versionIsPreview } from "./utils.js"; +import { html } from "lit"; + +/** + * @import { BrowserStatement } from "@mdn/browser-compat-data/types" + * @import { SupportStatementExtended } from "./types" + * @typedef {"no"|"yes"|"partial"|"preview"|"removed-partial"|"unknown"} SupportClassName + */ + +/** + * Returns a CSS class name based on support data and the browser. + * + * @param {SupportStatementExtended|undefined} support - The extended support statement. + * @param {BrowserStatement} browser - The browser statement. + * @returns {SupportClassName} + */ +export function getSupportClassName(support, browser) { + if (!support) { + return "unknown"; + } + + const currentSupport = getCurrentSupport(support); + if (!currentSupport) { + return "unknown"; + } + const { flags, version_added, version_removed, partial_implementation } = + currentSupport; + + /** @type {SupportClassName} */ + let className; + if (version_added === null) { + className = "unknown"; + } else if (versionIsPreview(version_added, browser)) { + className = "preview"; + } else if (version_added) { + className = "yes"; + if (version_removed || (flags && flags.length)) { + className = "no"; + } + } else { + className = "no"; + } + if (partial_implementation) { + className = version_removed ? "removed-partial" : "partial"; + } + + return className; +} + +/** + * Returns a label string derived from a version value. + * + * @param {string|boolean|null|undefined} version - The version value. + * @param {BrowserStatement} browser - The browser statement. + * @returns {string} The resulting label. + */ +export function labelFromString(version, browser) { + if (typeof version !== "string") { + return "?"; + } + if (version === "preview") { + return browser.preview_name ?? "Preview"; + } + // Treat BCD ranges as exact versions to avoid confusion for the reader + // See https://github.com/mdn/yari/issues/3238 + if (version.startsWith("≤")) { + version = version.slice(1); + } + // New: Omit trailing ".0". + version = version.replace(/(\.0)+$/g, ""); + + return version; +} + +/** + * Generates a version label from added and removed support data. + * + * @param {string|boolean|null|undefined} added - The added version. + * @param {string|boolean|null|undefined} removed - The removed version. + * @param {BrowserStatement} browser - The browser statement. + * @returns {import("lit").TemplateResult} A lit-html template result representing the version label. + */ +export function versionLabelFromSupport(added, removed, browser) { + if (typeof removed !== "string") { + return html`${labelFromString(added, browser)}`; + } + return html`${labelFromString( + added, + browser + )} – ${labelFromString(removed, browser)}`; +} + +/** + * Retrieves the browser release date from a support statement. + * + * @param {SupportStatementExtended|undefined} support - The extended support statement. + * @returns {string|undefined} The release date if available. + */ +export function getSupportBrowserReleaseDate(support) { + if (!support) { + return undefined; + } + + return getCurrentSupport(support)?.release_date; +} diff --git a/client/src/lit/compat/global.scss b/client/src/lit/compat/global.scss new file mode 100644 index 000000000000..923ca09ba860 --- /dev/null +++ b/client/src/lit/compat/global.scss @@ -0,0 +1,52 @@ +* { + box-sizing: border-box; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; +} + +.visually-hidden { + border: 0 !important; + clip: rect(1px, 1px, 1px, 1px) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + height: 1px !important; + margin: -1px !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + white-space: nowrap !important; + width: 1px !important; +} + +code { + background: var(--code-background-inline); + padding: 0.125rem 0.25rem; + width: fit-content; +} + +a { + color: var(--text-link); +} + +.external:after { + background-color: var(--icon-primary); + content: ""; + display: inline-flex; + height: 10px; + margin-left: 4px; + mask-image: url("../../assets/icons/external.svg"); + mask-size: cover; + width: 10px; +} diff --git a/client/src/lit/compat/index-desktop-md.scss b/client/src/lit/compat/index-desktop-md.scss new file mode 100644 index 000000000000..37ea54eb75d6 --- /dev/null +++ b/client/src/lit/compat/index-desktop-md.scss @@ -0,0 +1,28 @@ +@use "../../ui/vars" as *; + +@media (min-width: $screen-md) { + .table-container { + width: calc(100% + 6rem); + } + + .table-container-inner { + min-width: initial; + } + + .bc-on-github { + text-align: right; + } + + .bc-table { + // 25% for feature, 75% for browser columns. + grid-template-columns: minmax(25%, max-content) repeat( + var(--browser-count), + calc(75% / var(--browser-count)) + ); + } + + .icon { + // Workaround for Icons being cut by 1px at the top. + --size: calc(1rem + 1px); + } +} diff --git a/client/src/lit/compat/index-desktop-xl.scss b/client/src/lit/compat/index-desktop-xl.scss new file mode 100644 index 000000000000..f02434a4a3fd --- /dev/null +++ b/client/src/lit/compat/index-desktop-xl.scss @@ -0,0 +1,20 @@ +@use "../../ui/vars" as *; + +@media (min-width: $screen-xl) { + .table-container { + margin: 0; + width: 100%; + } + + .table-container-inner { + padding: 0; + } + + .bc-table { + // 33% for feature, 67% for browser columns. + grid-template-columns: minmax(33%, max-content) repeat( + var(--browser-count), + calc(67% / var(--browser-count)) + ); + } +} diff --git a/client/src/lit/compat/index-desktop.scss b/client/src/lit/compat/index-desktop.scss new file mode 100644 index 000000000000..ab3141f8ded7 --- /dev/null +++ b/client/src/lit/compat/index-desktop.scss @@ -0,0 +1,122 @@ +@use "../../ui/vars" as *; + +// Style for desktop. + +@media (min-width: $screen-sm) { + .bc-table { + // Expand all columns. + grid-template-columns: minmax(20vw, min-content) repeat( + var(--browser-count), + auto + ); + + thead { + display: contents; + + .bc-platforms { + th { + vertical-align: revert; + } + } + } + + tbody { + --border: 1px solid var(--border-secondary); + + // Border. + tr { + &:not(:first-child) { + th, + td { + > * { + border-top: var(--border); + } + } + + .bc-feature { + border-top: var(--border); + } + } + + th:not(:first-child), + td:not(:first-child) { + > * { + border-left: var(--border); + } + } + } + } + + td, + th { + background: inherit; + padding: 0.25rem; + } + + td.bc-support { + padding: 0; + + > button { + padding: 0.25rem; + } + } + + .timeline { + border-left: none !important; + border-top: var(--border); + } + + .bc-has-history:focus-within > button { + // Highlight expanded item. + --padding-bottom-offset: -2px; + border-bottom: 2px solid var(--text-primary); + } + } + + .table-container { + margin: 0 -3rem; + overflow: auto; + width: 100vw; + } + + .table-container-inner { + min-width: max-content; + padding: 0 3rem; + position: relative; + + &:after { + bottom: 0; + content: ""; + height: 10px; + position: absolute; + right: 0; + width: 10px; + } + } + + .bcd-cell-text-wrapper { + .bc-support-level, + .bc-browser-name { + display: none; + } + } + + .bc-notes-list { + margin-left: 20%; + width: auto; + } + + .bc-support { + .bc-support-level { + display: none; + } + } + + .bc-has-history { + cursor: pointer; + + > button:hover { + background: var(--background-secondary); + } + } +} diff --git a/client/src/lit/compat/index-mobile.scss b/client/src/lit/compat/index-mobile.scss new file mode 100644 index 000000000000..165cbdbec70f --- /dev/null +++ b/client/src/lit/compat/index-mobile.scss @@ -0,0 +1,51 @@ +@use "../../ui/vars" as *; + +// Style for mobile. + +@media (max-width: #{$screen-sm - 0.02}) { + .bc-table { + grid-template-columns: auto; + + thead { + display: none; + } + + tr { + td.bc-support { + border: none; + border-top: 1px solid var(--border-primary); + display: block; + + &:last-child { + border-bottom: 1px solid var(--border-primary); + } + } + } + + .timeline { + // Align timeline icons with summary icon. + margin-left: 0.25rem; + } + + tr:not(:first-of-type) .bc-feature { + // Feature separator. + border-top: 2px solid var(--border-primary); + } + + .bc-feature, + .bc-support > button { + align-content: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + } + + .bc-on-github a { + white-space: nowrap; + } + + .table-container { + overflow-x: auto; + } +} diff --git a/client/src/document/ingredients/browser-compatibility-table/index.scss b/client/src/lit/compat/index.scss similarity index 74% rename from client/src/document/ingredients/browser-compatibility-table/index.scss rename to client/src/lit/compat/index.scss index fef506359f7b..e5629ec393e3 100644 --- a/client/src/document/ingredients/browser-compatibility-table/index.scss +++ b/client/src/lit/compat/index.scss @@ -1,31 +1,76 @@ @use "sass:meta"; @use "~@mdn/minimalist/sass/mixins/utils" as *; -@use "../../../ui/vars" as *; +@use "../../ui/vars" as *; +@use "../../ui/atoms/button"; +@use "../../ui/atoms/icon"; + +@include meta.load-css("global"); // Style for mobile *and* desktop. +table { + display: grid; + grid-auto-flow: row dense; + + thead { + tr { + display: contents; + + th, + td { + display: grid; + grid-template-columns: subgrid; + } + } + } + + tbody { + display: contents; + + tr { + display: contents; + + th, + td { + display: contents; + + button { + display: grid; + grid-template-columns: subgrid; + } + + .timeline { + grid-column: 1 / -1; + } + } + } + } +} + +.bc-on-github { + font-size: var(--type-smaller-font-size); +} + .bc-table { border: 1px solid var(--border-primary); border-collapse: separate; border-radius: var(--elem-radius); border-spacing: 0; - margin: 1rem 0; + margin: 0; width: 100%; td, th { border: 1px solid var(--border-secondary); border-width: 0 0 1px 1px; + + font-size: var(--type-smaller-font-size); font-weight: 500; padding: 0; + padding: 0.4rem; - @media (min-width: $screen-md) { + code { font-size: var(--type-smaller-font-size); - padding: 0.4rem; - - code { - font-size: var(--type-smaller-font-size); - } } } @@ -35,6 +80,11 @@ vertical-align: bottom; } + thead { + line-height: 1; + white-space: nowrap; + } + // these props allow us to add border-radius to the table. // border-collapse: separate gets in the way of this // being easy. @@ -42,7 +92,7 @@ tr { height: 3rem; - @media (min-width: $screen-md) { + @media (min-width: $screen-sm) { &:last-child { th, td { @@ -81,17 +131,6 @@ color: var(--text-primary-green); } } - - .bc-history { - td { - border-left-width: 0; - } - - .icon.icon-removed-partial { - // override icon - mask-image: url("../../../assets/icons/partial.svg"); - } - } } .bc-supports { @@ -122,7 +161,12 @@ .icon.icon-removed-partial { background-color: var(--icon-critical); // override icon - mask-image: url("../../../assets/icons/no.svg"); + mask-image: url("../../assets/icons/no.svg"); + } + + .timeline .icon.icon-removed-partial { + background-color: var(--icon-primary); + mask-image: url("../../assets/icons/partial.svg"); } } @@ -132,32 +176,40 @@ } } + .bc-feature { + align-items: center; + border: none; + display: flex; + text-align: left; + width: 100%; + + > * { + border: none !important; + flex-basis: max-content; + } + } + .bc-feature-depth-2 { - border-left-width: 8px; + border-left: 7px solid var(--border-primary); } .bc-feature-depth-3 { - border-left-width: 16px; + border-left: 15px solid var(--border-primary); + } + + .bc-has-history:not(:focus-within) .timeline { + display: none; } } .bc-head-txt-label { - left: calc(50% - 0.5rem); line-height: 1; - padding-top: 0.5rem; - position: relative; text-orientation: sideways; transform: rotate(180deg); white-space: nowrap; - -ms-writing-mode: tb-rl; - -webkit-writing-mode: vertical-rl; writing-mode: vertical-rl; } -.bc-head-icon-symbol { - margin-bottom: 0.3rem; -} - .bc-support { text-align: center; vertical-align: middle; @@ -203,10 +255,10 @@ // Row with desktop / mobile icons. .bc-platforms { - height: 2rem; - th { - text-align: center; + align-items: center; + display: flex; + justify-content: center; } td { @@ -217,7 +269,12 @@ // Row with browser names. .bc-browsers { th { - text-align: center; + align-items: center; + display: flex; + flex-direction: column; + gap: 0.25rem; + justify-content: end; + vertical-align: bottom; } td { @@ -261,7 +318,7 @@ .bc-level-yes.icon.icon-yes { // override icon background-color: var(--icon-success); - mask-image: url("../../../assets/icons/yes-circle.svg"); + mask-image: url("../../assets/icons/yes-circle.svg"); } .bc-supports-dd { @@ -279,6 +336,7 @@ abbr { margin-right: 4px; + text-decoration: none; } dd { @@ -319,42 +377,24 @@ dl.bc-notes-list { } } -.offscreen, .only-icon span { @include visually-hidden(); } .bc-table-row-header { - align-items: baseline; - display: inline-flex; + padding: 0.25em; + text-align: left; width: 100%; code { overflow: hidden; } - .left-side, - .right-side { - overflow: hidden; - white-space: pre; - } - - /* Can only flex-shrink and not flex-grow - ie the "slider" in a sliding glass door */ - .left-side { - flex: 0 1 auto; - text-overflow: ellipsis; - } - /* Can flex-grow and not flex-shrink as - its the stationary portion */ - .right-side { - flex: 1 0 auto; - } - .bc-icons { - display: flex; - gap: 0.5rem; - margin-top: 0.25rem; + display: inline-flex; + gap: 0.5ch; + margin-left: 0.5ch; + vertical-align: text-top; .icon { background-color: var(--icon-secondary); @@ -395,7 +435,7 @@ dl.bc-notes-list { flex-direction: row; gap: 0.5rem; - @media (min-width: $screen-md) { + @media (min-width: $screen-sm) { align-items: center; flex-direction: column; } @@ -404,13 +444,13 @@ dl.bc-notes-list { .bcd-timeline-cell-text-wrapper { display: flex; flex-direction: row; - gap: 0.5rem; + gap: 0.25rem; } .bcd-cell-text-copy { color: var(--text-primary); display: flex; - gap: 0.5rem; + gap: 0.5ch; } .bc-supports-yes { @@ -419,15 +459,15 @@ dl.bc-notes-list { } } -.bc-supports-no { +.bc-supports-partial { .bcd-cell-text-copy { - color: var(--text-primary-red); + color: var(--text-primary-yellow); } } -.bc-supports-partial { +.bc-supports-no { .bcd-cell-text-copy { - color: var(--text-primary-yellow); + color: var(--text-primary-red); } } @@ -435,19 +475,18 @@ dl.bc-notes-list { display: flex; gap: 0.5rem; - @media (min-width: $screen-md) { + @media (min-width: $screen-sm) { display: block; } } -@media (min-width: $screen-md) { +@media (min-width: $screen-sm) { .bc-table { - td { - height: 2rem; - } - td.bc-support > button { - padding: 0.5rem 0.25rem; + padding-bottom: calc(0.5rem + var(--padding-bottom-offset, 0px)); + padding-left: 0.25rem; + padding-right: 0.25rem; + padding-top: 0.5rem; } } } diff --git a/client/src/lit/compat/lazy-compat-table.js b/client/src/lit/compat/lazy-compat-table.js new file mode 100644 index 000000000000..3bbf62b36c25 --- /dev/null +++ b/client/src/lit/compat/lazy-compat-table.js @@ -0,0 +1,73 @@ +import { LitElement, html } from "lit"; +import { createComponent } from "@lit/react"; +import { Task } from "@lit/task"; +import React from "react"; + +import { BCD_BASE_URL } from "../../env.ts"; +import "./compat-table.js"; + +/** + * @import { Browsers, Identifier } from "@mdn/browser-compat-data/types" + * @typedef {{data: Identifier, browsers: Browsers}} Compat + */ + +class LazyCompatTable extends LitElement { + static properties = { + query: {}, + locale: {}, + }; + + constructor() { + super(); + this.query = ""; + this.locale = ""; + } + + connectedCallback() { + super.connectedCallback(); + } + + _dataTask = new Task(this, { + args: () => [this.query], + task: async ([query], { signal }) => { + const response = await fetch( + `${BCD_BASE_URL}/bcd/api/v0/current/${query}.json`, + { signal } + ); + if (!response.ok) { + console.error("Failed to fetch BCD data:", response); + throw new Error(response.statusText); + } + return /** @type {Promise} */ response.json(); + }, + }); + + render() { + return this._dataTask.render({ + pending: () => html`

Loading...

`, + + complete: + /** + * @param {Compat} compat + */ + (compat) => + compat + ? html`` + : html`

No compatibility data found

`, + error: (error) => html`

Error loading data: ${error}

`, + }); + } +} + +customElements.define("lazy-compat-table", LazyCompatTable); + +export default createComponent({ + tagName: "lazy-compat-table", + elementClass: LazyCompatTable, + react: React, +}); diff --git a/client/src/lit/compat/legend.js b/client/src/lit/compat/legend.js new file mode 100644 index 000000000000..0e838b8cc1b0 --- /dev/null +++ b/client/src/lit/compat/legend.js @@ -0,0 +1,125 @@ +import { + HIDDEN_BROWSERS, + asList, + getFirst, + hasMore, + hasNoteworthyNotes, + listFeatures, + versionIsPreview, +} from "./utils.js"; + +/** + * @import { BrowserName, Browsers, Identifier } from "@mdn/browser-compat-data/types" + * @typedef {"yes" | "partial" | "preview" | "no" | "unknown" | "experimental" | "nonstandard" | "deprecated" | "footnote" | "disabled" | "altname" | "prefix" | "more"} LegendKey + */ + +/** + * Legend labels which also specifies the order in which the legend appears. + * @type {Record} + */ +export const LEGEND_LABELS = { + yes: "Full support", + partial: "Partial support", + preview: "In development. Supported in a pre-release version.", + no: "No support", + unknown: "Compatibility unknown", + experimental: "Experimental. Expect behavior to change in the future.", + nonstandard: "Non-standard. Check cross-browser support before using.", + deprecated: "Deprecated. Not for use in new websites.", + footnote: "See implementation notes.", + disabled: "User must explicitly enable this feature.", + altname: "Uses a non-standard name.", + prefix: "Requires a vendor prefix or different name for use.", + more: "Has more compatibility info.", +}; + +/** + * Gets the active legend items based on browser compatibility data. + * + * @param {Identifier} compat - The compatibility data identifier. + * @param {string} name - The name of the feature. + * @param {Browsers} browserInfo - Information about browsers. + * @param {BrowserName[]} browsers - The list of displayed browsers. + * @returns {Array<[LegendKey, string]>} An array of legend item entries, where each entry is a tuple of the legend key and its label. + */ +export function getActiveLegendItems(compat, name, browserInfo, browsers) { + /** @type {Set} */ + const legendItems = new Set(); + + for (const feature of listFeatures(compat, "", name)) { + const { status } = feature.compat; + + if (status) { + if (status.experimental) { + legendItems.add("experimental"); + } + if (status.deprecated) { + legendItems.add("deprecated"); + } + if (!status.standard_track) { + legendItems.add("nonstandard"); + } + } + + for (const browser of browsers) { + const browserSupport = feature.compat.support[browser] ?? { + version_added: null, + }; + + if (HIDDEN_BROWSERS.includes(browser)) { + continue; + } + + const firstSupportItem = getFirst(browserSupport); + if (firstSupportItem && hasNoteworthyNotes(firstSupportItem)) { + legendItems.add("footnote"); + } + + for (const versionSupport of asList(browserSupport)) { + if (versionSupport.version_added) { + if (versionSupport.flags && versionSupport.flags.length) { + legendItems.add("no"); + } else if ( + versionIsPreview(versionSupport.version_added, browserInfo[browser]) + ) { + legendItems.add("preview"); + } else { + legendItems.add("yes"); + } + } else if (versionSupport.version_added == null) { + legendItems.add("unknown"); + } else { + legendItems.add("no"); + } + + if (versionSupport.partial_implementation) { + legendItems.add("partial"); + } + if (versionSupport.prefix) { + legendItems.add("prefix"); + } + if (versionSupport.alternative_name) { + legendItems.add("altname"); + } + if (versionSupport.flags) { + legendItems.add("disabled"); + } + } + + if (hasMore(browserSupport)) { + legendItems.add("more"); + } + } + } + + const keys = /** @type {LegendKey[]} */ (Object.keys(LEGEND_LABELS)); + + return keys + .filter((key) => legendItems.has(key)) + .map( + /** + * @param {LegendKey} key + */ + (key) => [key, LEGEND_LABELS[key]] + ); +} diff --git a/client/src/lit/compat/types.ts b/client/src/lit/compat/types.ts new file mode 100644 index 000000000000..ae8a4a0ba7a7 --- /dev/null +++ b/client/src/lit/compat/types.ts @@ -0,0 +1,17 @@ +import type BCD from "@mdn/browser-compat-data/types"; + +// Extended for the fields, beyond the bcd types, that are extra-added +// exclusively in Yari. +export interface SimpleSupportStatementExtended + extends BCD.SimpleSupportStatement { + // Known for some support statements where the browser *version* is known, + // as opposed to just "true" and if the version release date is known. + release_date?: string; + // The version before the version_removed if the *version* removed is known, + // as opposed to just "true". Otherwise the version_removed. + version_last?: BCD.VersionValue; +} + +export type SupportStatementExtended = + | SimpleSupportStatementExtended + | SimpleSupportStatementExtended[]; diff --git a/client/src/lit/compat/utils.js b/client/src/lit/compat/utils.js new file mode 100644 index 000000000000..0e40a8a2e670 --- /dev/null +++ b/client/src/lit/compat/utils.js @@ -0,0 +1,302 @@ +/** + * @import { SimpleSupportStatement, VersionValue, CompatStatement, Identifier, SupportStatement, BrowserStatement } from "@mdn/browser-compat-data/types" + * @import { SupportStatementExtended, SimpleSupportStatementExtended } from "./types" + */ + +/** + */ + +/** + * @typedef {Object} Feature + * @property {string} name + * @property {CompatStatement} compat + * @property {number} depth + */ + +/** + * A list of browsers to be hidden. + * @constant {string[]} + */ +export const HIDDEN_BROWSERS = ["ie"]; + +/** + * Gets the first element of an array or returns the value itself. + * + * @template T + * @param {T | [T?, ...any[]]} a + * @returns {T | undefined} + */ +export function getFirst(a) { + return Array.isArray(a) ? a[0] : a; +} + +/** + * Ensures the input is returned as an array. + * + * @template T + * @param {T | T[]} a + * @returns {T[]} + */ +export function asList(a) { + return Array.isArray(a) ? a : [a]; +} + +/** + * Finds the first compatibility depth in a BCD Identifier. + * + * @param {Identifier} identifier + * @returns {number} + */ +function findFirstCompatDepth(identifier) { + /** @type {Array<[string, Identifier]>} */ + const entries = [["", identifier]]; + + do { + const entry = entries.shift(); + if (!entry) { + break; + } + const [path, value] = entry; + if (value.__compat) { + // The depth is the number of segments in the path. + return path.split(".").length; + } + + for (const [key, subvalue] of Object.entries(value)) { + const subpath = path ? `${path}.${key}` : key; + if ("__compat" in subvalue) { + entries.push([subpath, subvalue]); + } + } + } while (entries.length); + + // Fallback. + return 0; +} + +/** + * Recursively lists features from a BCD Identifier. + * + * @param {Identifier} identifier + * @param {string} [parentName=""] + * @param {string} [rootName=""] + * @param {number} [depth=0] + * @param {number} [firstCompatDepth=0] + * @returns {Feature[]} + */ +export function listFeatures( + identifier, + parentName = "", + rootName = "", + depth = 0, + firstCompatDepth = 0 +) { + /** @type {Feature[]} */ + const features = []; + + if (rootName && identifier.__compat) { + features.push({ + name: rootName, + compat: identifier.__compat, + depth, + }); + } + + if (rootName) { + firstCompatDepth = findFirstCompatDepth(identifier); + } + + for (const [subName, subIdentifier] of Object.entries(identifier)) { + if (subName === "__compat") { + continue; + } + + if ("__compat" in subIdentifier && subIdentifier.__compat) { + features.push({ + name: parentName ? `${parentName}.${subName}` : subName, + compat: subIdentifier.__compat, + depth: depth + 1, + }); + } + + if ("__compat" in subIdentifier /* || depth + 1 < firstCompatDepth*/) { + features.push( + ...listFeatures(subIdentifier, subName, "", depth + 1, firstCompatDepth) + ); + } + } + return features; +} + +/** + * Checks if the support statement is an array with more than one item. + * + * @param {SupportStatement | undefined} support + * @returns {boolean} + */ +export function hasMore(support) { + return Array.isArray(support) && support.length > 1; +} + +/** + * Determines if a version is a preview version. + * + * @param {string | VersionValue | undefined} version + * @param {BrowserStatement} browser + * @returns {boolean} + */ +export function versionIsPreview(version, browser) { + if (version === "preview") { + return true; + } + + if (browser && typeof version === "string" && browser.releases[version]) { + return ["beta", "nightly", "planned"].includes( + browser.releases[version].status + ); + } + + return false; +} + +/** + * Checks if the support statement has noteworthy notes. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +export function hasNoteworthyNotes(support) { + return ( + !!( + (support.notes && support.notes.length) || + (support.impl_url && support.impl_url.length) + ) && + !support.version_removed && + !support.partial_implementation + ); +} + +/** + * Converts a bug URL to a simplified string. + * + * @param {string} url + * @returns {string} + */ +export function bugURLToString(url) { + const match = url.match( + /^https:\/\/(?:crbug\.com|webkit\.org\/b|bugzil\.la)\/([0-9]+)/i + ); + const bugNumber = match ? match[1] : null; + return bugNumber ? `bug ${bugNumber}` : url; +} + +/** + * Checks if a support statement has any limitation. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +function hasLimitation(support) { + return hasMajorLimitation(support) || !!support.notes || !!support.impl_url; +} + +/** + * Checks if a support statement has major limitations. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +function hasMajorLimitation(support) { + return ( + support.partial_implementation || + !!support.alternative_name || + !!support.flags || + !!support.prefix || + !!support.version_removed + ); +} + +/** + * Checks if a support statement is fully supported without any limitation. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +export function isFullySupportedWithoutLimitation(support) { + return !!support.version_added && !hasLimitation(support); +} + +/** + * Checks if a support statement is not supported at all. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +export function isNotSupportedAtAll(support) { + return support.version_added === false && !hasLimitation(support); +} + +/** + * Checks if a support statement is fully supported without major limitations. + * + * @param {SimpleSupportStatement} support + * @returns {boolean} + */ +function isFullySupportedWithoutMajorLimitation(support) { + return !!support.version_added && !hasMajorLimitation(support); +} + +/** + * Gets the current support statement from a support statement extended. + * + * Prioritizes support items in the following order: + * 1. Full support without limitation. + * 2. Full support with only notes and version_added. + * 3. Full support with alternative name or prefix. + * 4. Partial support. + * 5. Support with flags only. + * 6. No/Inactive support. + * + * @param {SupportStatementExtended | undefined} support + * @returns {SimpleSupportStatementExtended | undefined} + */ +export function getCurrentSupport(support) { + if (!support) return undefined; + + // Full support without limitation. + const noLimitationSupportItem = asList(support).find((item) => + isFullySupportedWithoutLimitation(item) + ); + if (noLimitationSupportItem) return noLimitationSupportItem; + + // Full support with only notes and version_added. + const minorLimitationSupportItem = asList(support).find((item) => + isFullySupportedWithoutMajorLimitation(item) + ); + if (minorLimitationSupportItem) return minorLimitationSupportItem; + + // Full support with alternative name/prefix. + const altnamePrefixSupportItem = asList(support).find( + (item) => !item.version_removed && (item.prefix || item.alternative_name) + ); + if (altnamePrefixSupportItem) return altnamePrefixSupportItem; + + // Partial support. + const partialSupportItem = asList(support).find( + (item) => !item.version_removed && item.partial_implementation + ); + if (partialSupportItem) return partialSupportItem; + + // Support with flags only. + const flagSupportItem = asList(support).find( + (item) => !item.version_removed && item.flags + ); + if (flagSupportItem) return flagSupportItem; + + // No/Inactive support. + const noSupportItem = asList(support).find((item) => item.version_removed); + if (noSupportItem) return noSupportItem; + + // Default (likely never reached). + return getFirst(support); +} diff --git a/client/src/plus/updates/index.tsx b/client/src/plus/updates/index.tsx index acab9a9ab6ae..f5b52d8ee20d 100644 --- a/client/src/plus/updates/index.tsx +++ b/client/src/plus/updates/index.tsx @@ -3,8 +3,6 @@ import Container from "../../ui/atoms/container"; import useSWR from "swr"; import { DocMetadata } from "../../../../libs/types/document"; import { FeatureId, MDN_PLUS_TITLE } from "../../constants"; -import BrowserCompatibilityTable from "../../document/ingredients/browser-compatibility-table"; -import { browserToIconName } from "../../document/ingredients/browser-compatibility-table/headers"; import { useLocale, useScrollToTop, useViewedState } from "../../hooks"; import { Button } from "../../ui/atoms/button"; import { Icon } from "../../ui/atoms/icon"; @@ -14,7 +12,7 @@ import { Paginator } from "../../ui/molecules/paginator"; import BookmarkMenu from "../../ui/organisms/article-actions/bookmark-menu"; import { useUserData } from "../../user-context"; import { camelWrap, range } from "../../utils"; -import { Event, Group, useBCD, useUpdates } from "./api"; +import { Event, Group, useUpdates } from "./api"; import "./index.scss"; import { useGleanClick } from "../../telemetry/glean-context"; import { PLUS_UPDATES } from "../../telemetry/constants"; @@ -24,6 +22,11 @@ import { useSearchParams } from "react-router-dom"; import { DataError } from "../common"; import { useCollections } from "../collections/api"; import { PlusLoginBanner } from "../common/login-banner"; +import React from "react"; + +const LazyCompatTable = React.lazy( + () => import("../../lit/compat/lazy-compat-table.js") +); type EventWithStatus = Event & { status: Status }; type Status = "added" | "removed"; @@ -351,18 +354,10 @@ function EventInnerComponent({ event: Event; }) { const locale = useLocale(); - const { data } = useBCD(path); return (
- {data && ( - - )} +
); } @@ -412,3 +407,16 @@ function ArticleActions({ path, mdn_url }: { path: string; mdn_url?: string }) { ); } + +function browserToIconName(browser: string) { + if (browser.startsWith("firefox")) { + return "simple-firefox"; + } else if (browser === "webview_android") { + return "webview"; + } else if (browser === "webview_ios") { + return "safari"; + } else { + const browserStart = browser.split("_")[0]; + return browserStart; + } +}