diff --git a/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts b/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts
new file mode 100644
index 0000000000..74360a015b
--- /dev/null
+++ b/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import { test, expect } from "../../fixtures/extensionBase";
+import { ActivateModPage } from "../../pageObjects/extensionConsole/modsPage";
+import { getSidebarPage, runModViaQuickBar } from "../../utils";
+import type { Page } from "@playwright/test";
+
+test("custom sidebar theme css file is applied to all levels of sidebar document", async ({
+ page,
+ extensionId,
+}) => {
+ const modId = "@pixies/testing/panel-theme";
+
+ const modActivationPage = new ActivateModPage(page, extensionId, modId);
+ await modActivationPage.goto();
+
+ await modActivationPage.clickActivateAndWaitForModsPageRedirect();
+
+ await page.goto("/");
+
+ // Ensure the page is focused by clicking on an element before running the keyboard shortcut, see runModViaQuickbar
+ await page.getByText("Index of /").click();
+ await runModViaQuickBar(page, "Show Sidebar");
+
+ const sidebarPage = (await getSidebarPage(page, extensionId)) as Page;
+ await expect(
+ sidebarPage.getByText("#8347: Theme Inheritance", { exact: true }),
+ ).toBeVisible();
+
+ const green = "rgb(0, 128, 0)";
+ const elementsThatShouldBeGreen = await sidebarPage
+ .getByText("This should be green")
+ .all();
+ await Promise.all(
+ elementsThatShouldBeGreen.map(async (element) =>
+ expect(element).toHaveCSS("color", green),
+ ),
+ );
+});
diff --git a/src/bricks/renderers/CustomFormComponent.tsx b/src/bricks/renderers/CustomFormComponent.tsx
index 9d081cb6eb..66263fa042 100644
--- a/src/bricks/renderers/CustomFormComponent.tsx
+++ b/src/bricks/renderers/CustomFormComponent.tsx
@@ -34,6 +34,7 @@ import DescriptionField from "@/components/formBuilder/DescriptionField";
import TextAreaWidget from "@/components/formBuilder/TextAreaWidget";
import RjsfSubmitContext from "@/components/formBuilder/RjsfSubmitContext";
import { cloneDeep } from "lodash";
+import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext";
const FIELDS = {
DescriptionField,
@@ -65,6 +66,7 @@ export type CustomFormComponentProps = {
resetOnSubmit?: boolean;
className?: string;
stylesheets?: string[];
+ disableParentStyles?: boolean;
};
const CustomFormComponent: React.FunctionComponent<
@@ -78,7 +80,8 @@ const CustomFormComponent: React.FunctionComponent<
className,
onSubmit,
resetOnSubmit = false,
- stylesheets,
+ disableParentStyles = false,
+ stylesheets: newStylesheets,
}) => {
// Use useRef instead of useState because we don't need/want a re-render when count changes
// This ref is used to track the onSubmit run number for runtime tracing
@@ -99,6 +102,11 @@ const CustomFormComponent: React.FunctionComponent<
setKey((prev) => prev + 1);
};
+ const { stylesheets } = useStylesheetsContextWithFormDefault({
+ newStylesheets,
+ disableParentStyles,
+ });
+
const submitData = async (data: UnknownObject): Promise => {
submissionCountRef.current += 1;
await onSubmit(data, {
diff --git a/src/bricks/renderers/documentView/DocumentView.tsx b/src/bricks/renderers/documentView/DocumentView.tsx
index d1f65c5d24..fdb7cc3cba 100644
--- a/src/bricks/renderers/documentView/DocumentView.tsx
+++ b/src/bricks/renderers/documentView/DocumentView.tsx
@@ -23,10 +23,14 @@ import { type DocumentViewProps } from "./DocumentViewProps";
import DocumentContext from "@/components/documentBuilder/render/DocumentContext";
import { Stylesheets } from "@/components/Stylesheets";
import { joinPathParts } from "@/utils/formUtils";
+import StylesheetsContext, {
+ useStylesheetsContextWithDocumentDefault,
+} from "@/components/StylesheetsContext";
const DocumentView: React.FC = ({
body,
- stylesheets,
+ stylesheets: newStylesheets,
+ disableParentStyles,
options,
meta,
onAction,
@@ -41,26 +45,33 @@ const DocumentView: React.FC = ({
throw new Error("meta.extensionId is required for DocumentView");
}
+ const { stylesheets } = useStylesheetsContextWithDocumentDefault({
+ newStylesheets,
+ disableParentStyles,
+ });
+
return (
// Wrap in a React context provider that passes BrickOptions down to any embedded bricks
-
- {body.map((documentElement, index) => {
- const documentBranch = buildDocumentBranch(documentElement, {
- staticId: joinPathParts("body", "children"),
- // Root of the document, so no branches taken yet
- branches: [],
- });
+
+
+ {body.map((documentElement, index) => {
+ const documentBranch = buildDocumentBranch(documentElement, {
+ staticId: joinPathParts("body", "children"),
+ // Root of the document, so no branches taken yet
+ branches: [],
+ });
- if (documentBranch == null) {
- return null;
- }
+ if (documentBranch == null) {
+ return null;
+ }
- const { Component, props } = documentBranch;
- // eslint-disable-next-line react/no-array-index-key -- They have no other unique identifier
- return ;
- })}
-
+ const { Component, props } = documentBranch;
+ // eslint-disable-next-line react/no-array-index-key -- They have no other unique identifier
+ return ;
+ })}
+
+
);
};
diff --git a/src/bricks/renderers/documentView/DocumentViewProps.tsx b/src/bricks/renderers/documentView/DocumentViewProps.tsx
index 65fca3255c..f677846a6f 100644
--- a/src/bricks/renderers/documentView/DocumentViewProps.tsx
+++ b/src/bricks/renderers/documentView/DocumentViewProps.tsx
@@ -33,6 +33,10 @@ export type DocumentViewProps = {
* Remote stylesheets (URLs) to include in the document.
*/
stylesheets?: string[];
+ /**
+ * Whether to disable the base (bootstrap) styles, plus any inherited styles, on the document (and children).
+ */
+ disableParentStyles?: boolean;
options: BrickOptions;
meta: {
diff --git a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx
index 689ba4c3e4..d4e2a42748 100644
--- a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx
+++ b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx
@@ -32,6 +32,7 @@ import DescriptionField from "@/components/formBuilder/DescriptionField";
import RjsfSelectWidget from "@/components/formBuilder/RjsfSelectWidget";
import TextAreaWidget from "@/components/formBuilder/TextAreaWidget";
import { Stylesheets } from "@/components/Stylesheets";
+import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext";
export const fields = {
DescriptionField,
@@ -55,8 +56,21 @@ const EphemeralFormContent: React.FC = ({
nonce,
isModal,
}) => {
- const { schema, uiSchema, cancelable, submitCaption, stylesheets } =
- definition;
+ const {
+ schema,
+ uiSchema,
+ cancelable,
+ submitCaption,
+ stylesheets: newStylesheets,
+ disableParentStyles,
+ } = definition;
+
+ // Ephemeral form can never be nested, but we use this to pull in
+ // the (boostrap) base themes
+ const { stylesheets } = useStylesheetsContextWithFormDefault({
+ newStylesheets,
+ disableParentStyles: disableParentStyles ?? false,
+ });
return (
diff --git a/src/components/StylesheetsContext.ts b/src/components/StylesheetsContext.ts
new file mode 100644
index 0000000000..d5a7c831da
--- /dev/null
+++ b/src/components/StylesheetsContext.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2024 PixieBrix, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+import React, { useContext } from "react";
+import bootstrap from "@/vendors/bootstrapWithoutRem.css?loadAsUrl";
+import bootstrapOverrides from "@/sidebar/sidebarBootstrapOverrides.scss?loadAsUrl";
+import custom from "@/bricks/renderers/customForm.css?loadAsUrl";
+
+export type StylesheetsContextType = {
+ stylesheets: string[] | null;
+};
+
+const StylesheetsContext = React.createContext({
+ stylesheets: null,
+});
+
+function useStylesheetsContextWithDefaultValues({
+ newStylesheets,
+ defaultStylesheets,
+ disableParentStyles,
+}: {
+ newStylesheets: string[] | undefined;
+ defaultStylesheets: string[];
+ disableParentStyles: boolean;
+}): {
+ stylesheets: string[];
+} {
+ const { stylesheets: inheritedStylesheets } = useContext(StylesheetsContext);
+
+ const stylesheets: string[] = [];
+
+ if (!disableParentStyles) {
+ if (inheritedStylesheets == null) {
+ stylesheets.push(...defaultStylesheets);
+ } else {
+ stylesheets.push(...inheritedStylesheets);
+ }
+ }
+
+ if (newStylesheets != null) {
+ stylesheets.push(...newStylesheets);
+ }
+
+ return { stylesheets };
+}
+
+export function useStylesheetsContextWithDocumentDefault({
+ newStylesheets,
+ disableParentStyles,
+}: {
+ newStylesheets: string[] | undefined;
+ disableParentStyles: boolean;
+}): {
+ stylesheets: string[];
+} {
+ return useStylesheetsContextWithDefaultValues({
+ newStylesheets,
+ defaultStylesheets: [
+ bootstrap,
+ bootstrapOverrides,
+ // DocumentView.css is an artifact produced by webpack, see the DocumentView entrypoint included in
+ // `webpack.config.mjs`. We build styles needed to render documents separately from the rest of the sidebar
+ // in order to isolate the rendered document from the custom Bootstrap theme included in the Sidebar app
+ "/DocumentView.css",
+ // Required because it can be nested in the DocumentView.
+ "/CustomFormComponent.css",
+ ],
+ disableParentStyles,
+ });
+}
+
+export function useStylesheetsContextWithFormDefault({
+ newStylesheets,
+ disableParentStyles,
+}: {
+ newStylesheets: string[] | undefined;
+ disableParentStyles: boolean;
+}): {
+ stylesheets: string[];
+} {
+ return useStylesheetsContextWithDefaultValues({
+ newStylesheets,
+ defaultStylesheets: [
+ bootstrap,
+ bootstrapOverrides,
+ // CustomFormComponent.css and EphemeralFormContent.css are artifacts produced by webpack, see the entrypoints.
+ "/EphemeralFormContent.css",
+ "/CustomFormComponent.css",
+ custom,
+ ],
+ disableParentStyles,
+ });
+}
+
+export default StylesheetsContext;
diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json
index 182122cf49..2422965741 100644
--- a/src/tsconfig.strictNullChecks.json
+++ b/src/tsconfig.strictNullChecks.json
@@ -250,6 +250,7 @@
"./components/StopPropagation.tsx",
"./components/Stylesheets.test.tsx",
"./components/Stylesheets.tsx",
+ "./components/StylesheetsContext.ts",
"./components/TooltipIconButton.tsx",
"./components/UnstyledButton.tsx",
"./components/addBlockModal/TagList.tsx",