diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ResponseSchema/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ResponseSchema/index.tsx
index 1774c0193..0b736b7a5 100644
--- a/packages/docusaurus-theme-openapi-docs/src/theme/ResponseSchema/index.tsx
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/ResponseSchema/index.tsx
@@ -18,6 +18,7 @@ import {
ResponseExamples,
} from "@theme/ResponseExamples";
import SchemaNode from "@theme/Schema";
+import SchemaExpansionControl from "@theme/SchemaExpansion";
import SchemaTabs from "@theme/SchemaTabs";
import SkeletonLoader from "@theme/SkeletonLoader";
import TabItem from "@theme/TabItem";
@@ -91,21 +92,20 @@ const ResponseSchemaComponent: React.FC
= ({
open={true}
style={style}
summary={
- <>
-
-
- {title}
- {body.required === true && (
-
- {translate({
- id: OPENAPI_SCHEMA_ITEM.REQUIRED,
- message: "required",
- })}
-
- )}
-
-
- >
+
+
+ {title}
+ {body.required === true && (
+
+ {translate({
+ id: OPENAPI_SCHEMA_ITEM.REQUIRED,
+ message: "required",
+ })}
+
+ )}
+
+
+
}
>
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx
index 5ae8f4dfb..9556b7046 100644
--- a/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/Schema/index.tsx
@@ -14,6 +14,11 @@ import { ClosingArrayBracket, OpeningArrayBracket } from "@theme/ArrayBrackets";
import Details from "@theme/Details";
import DiscriminatorTabs from "@theme/DiscriminatorTabs";
import Markdown from "@theme/Markdown";
+import {
+ SchemaDepthProvider,
+ useSchemaDepth,
+ useSchemaExpansion,
+} from "@theme/SchemaExpansion";
import SchemaItem from "@theme/SchemaItem";
import SchemaTabs from "@theme/SchemaTabs";
import TabItem from "@theme/TabItem";
@@ -676,10 +681,15 @@ const SchemaNodeDetails: React.FC = ({
schemaType,
schemaPath,
}) => {
+ const depth = useSchemaDepth();
+ const { level } = useSchemaExpansion();
+ const defaultOpen = depth < level;
return (
= ({
{getQualifierMessage(schema) && (
)}
-
+
+
+
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss
new file mode 100644
index 000000000..baa7c643a
--- /dev/null
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss
@@ -0,0 +1,113 @@
+/* ============================================================================
+ * Copyright (c) Palo Alto Networks
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ * ========================================================================== */
+
+.openapi-schema-expansion {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ margin-left: auto;
+}
+
+.openapi-schema-expansion__trigger {
+ appearance: none;
+ background: transparent;
+ border: none;
+ border-radius: var(--ifm-global-radius);
+ padding: 0;
+ width: 22px;
+ height: 22px;
+ flex: 0 0 22px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--ifm-color-emphasis-600);
+ line-height: 0;
+ opacity: 0.7;
+ transition:
+ opacity 0.12s ease,
+ background-color 0.12s ease,
+ color 0.12s ease;
+
+ &:hover {
+ opacity: 1;
+ background-color: var(--ifm-color-emphasis-200);
+ color: var(--ifm-color-emphasis-900);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 2px;
+ opacity: 1;
+ }
+
+ &[aria-expanded="true"] {
+ opacity: 1;
+ background-color: var(--ifm-color-emphasis-200);
+ color: var(--ifm-color-emphasis-900);
+ }
+}
+
+.openapi-schema-expansion__popover {
+ position: fixed;
+ z-index: 1000;
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 3px;
+ background-color: var(--ifm-background-surface-color);
+ border: 1px solid var(--ifm-color-emphasis-300);
+ border-radius: var(--ifm-global-radius);
+ box-shadow: var(--ifm-global-shadow-md);
+ white-space: nowrap;
+}
+
+.openapi-schema-expansion__option {
+ appearance: none;
+ background: transparent;
+ border: none;
+ border-radius: calc(var(--ifm-global-radius) - 2px);
+ padding: 2px 8px;
+ font-size: 0.8rem;
+ line-height: 1.4;
+ color: var(--ifm-color-emphasis-800);
+ transition:
+ background-color 0.12s ease,
+ color 0.12s ease;
+
+ &:hover {
+ background-color: var(--ifm-color-emphasis-200);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ifm-color-primary);
+ outline-offset: 1px;
+ }
+}
+
+.openapi-schema-expansion__option--active {
+ background-color: var(--ifm-color-primary);
+ color: var(--ifm-color-white);
+
+ &:hover {
+ background-color: var(--ifm-color-primary-dark);
+ }
+}
+
+.openapi-markdown__details-summary--with-control {
+ display: flex !important;
+ align-items: center;
+ gap: 0.5rem;
+
+ > h3,
+ > strong {
+ margin-bottom: 0;
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ }
+}
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx
new file mode 100644
index 000000000..a27176288
--- /dev/null
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx
@@ -0,0 +1,154 @@
+/* ============================================================================
+ * Copyright (c) Palo Alto Networks
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ * ========================================================================== */
+
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
+
+import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
+import type { ThemeConfig } from "docusaurus-theme-openapi-docs/src/types";
+
+export const SCHEMA_EXPANSION_STORAGE_KEY =
+ "docusaurus-openapi-schema-expansion-level";
+
+export interface SchemaExpansionConfig {
+ enabled: boolean;
+ defaultLevel: number;
+ max: number;
+ persist: boolean;
+}
+
+interface SchemaExpansionContextValue {
+ config: SchemaExpansionConfig;
+ level: number;
+ setLevel: (next: number) => void;
+}
+
+const DEFAULT_CONFIG: SchemaExpansionConfig = {
+ enabled: false,
+ defaultLevel: 0,
+ max: 4,
+ persist: true,
+};
+
+const SchemaExpansionContext = createContext({
+ config: DEFAULT_CONFIG,
+ level: 0,
+ setLevel: () => {},
+});
+
+const SchemaDepthContext = createContext(0);
+
+export function normalizeLevel(value: number | "all" | undefined): number {
+ if (value === "all") return Infinity;
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
+ return Math.floor(value);
+ }
+ return 0;
+}
+
+function readConfig(
+ themeConfig: ThemeConfig | undefined
+): SchemaExpansionConfig {
+ const raw = themeConfig?.api?.schemaExpansion;
+ if (!raw) return DEFAULT_CONFIG;
+ const enabled = raw.enabled ?? false;
+ return {
+ enabled,
+ defaultLevel: normalizeLevel(raw.default),
+ max: typeof raw.max === "number" && raw.max > 0 ? Math.floor(raw.max) : 4,
+ // Persistence only matters when the reader can change the level via the
+ // UI control. When the control is hidden, fall back to the configured
+ // default on every visit so it isn't shadowed by a stale localStorage
+ // value from a session where the control used to be enabled.
+ persist: enabled ? (raw.persist ?? true) : false,
+ };
+}
+
+function readPersistedLevel(): number | undefined {
+ if (typeof window === "undefined") return undefined;
+ try {
+ const stored = window.localStorage.getItem(SCHEMA_EXPANSION_STORAGE_KEY);
+ if (stored === null) return undefined;
+ if (stored === "all") return Infinity;
+ const parsed = parseInt(stored, 10);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function writePersistedLevel(level: number): void {
+ if (typeof window === "undefined") return;
+ try {
+ const value = level === Infinity ? "all" : String(level);
+ window.localStorage.setItem(SCHEMA_EXPANSION_STORAGE_KEY, value);
+ } catch {
+ // ignore quota / disabled storage
+ }
+}
+
+export const SchemaExpansionProvider: React.FC<{
+ children: React.ReactNode;
+}> = ({ children }) => {
+ const { siteConfig } = useDocusaurusContext();
+ const themeConfig = siteConfig.themeConfig as ThemeConfig | undefined;
+ const config = useMemo(() => readConfig(themeConfig), [themeConfig]);
+
+ const [level, setLevelState] = useState(config.defaultLevel);
+
+ useEffect(() => {
+ if (!config.persist) return;
+ const persisted = readPersistedLevel();
+ if (persisted !== undefined) {
+ setLevelState(persisted);
+ }
+ }, [config.persist]);
+
+ const setLevel = useCallback(
+ (next: number) => {
+ setLevelState(next);
+ if (config.persist) {
+ writePersistedLevel(next);
+ }
+ },
+ [config.persist]
+ );
+
+ const value = useMemo(
+ () => ({ config, level, setLevel }),
+ [config, level, setLevel]
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const SchemaDepthProvider: React.FC<{
+ depth: number;
+ children: React.ReactNode;
+}> = ({ depth, children }) => (
+
+ {children}
+
+);
+
+export function useSchemaExpansion(): SchemaExpansionContextValue {
+ return useContext(SchemaExpansionContext);
+}
+
+export function useSchemaDepth(): number {
+ return useContext(SchemaDepthContext);
+}
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx
new file mode 100644
index 000000000..abd518623
--- /dev/null
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx
@@ -0,0 +1,237 @@
+/* ============================================================================
+ * Copyright (c) Palo Alto Networks
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ * ========================================================================== */
+
+import React, {
+ useCallback,
+ useEffect,
+ useId,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+import { translate } from "@docusaurus/Translate";
+import { OPENAPI_SCHEMA_EXPANSION } from "@theme/translationIds";
+import clsx from "clsx";
+
+import { useSchemaExpansion } from "./context";
+
+export {
+ SchemaExpansionProvider,
+ SchemaDepthProvider,
+ useSchemaExpansion,
+ useSchemaDepth,
+ normalizeLevel,
+ SCHEMA_EXPANSION_STORAGE_KEY,
+} from "./context";
+
+const ALL_VALUE = Number.POSITIVE_INFINITY;
+
+const ExpandIcon: React.FC = () => (
+
+);
+
+const SchemaExpansionControl: React.FC = () => {
+ const { config, level, setLevel } = useSchemaExpansion();
+ const [open, setOpen] = useState(false);
+ const [coords, setCoords] = useState<{ top: number; right: number } | null>(
+ null
+ );
+ const buttonRef = useRef(null);
+ const popoverRef = useRef(null);
+ const optionRefs = useRef>([]);
+ const popoverId = useId();
+
+ const options = useMemo(() => {
+ const numbers = Array.from({ length: config.max + 1 }, (_, i) => i);
+ return [...numbers, ALL_VALUE];
+ }, [config.max]);
+
+ const activeIndex = useMemo(() => {
+ const idx = options.indexOf(level);
+ return idx >= 0 ? idx : 0;
+ }, [options, level]);
+
+ const updatePosition = useCallback(() => {
+ if (!buttonRef.current || typeof window === "undefined") return;
+ const rect = buttonRef.current.getBoundingClientRect();
+ setCoords({
+ top: rect.bottom + 4,
+ right: window.innerWidth - rect.right,
+ });
+ }, []);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ updatePosition();
+ const handleScroll = () => setOpen(false);
+ window.addEventListener("scroll", handleScroll, true);
+ window.addEventListener("resize", updatePosition);
+ return () => {
+ window.removeEventListener("scroll", handleScroll, true);
+ window.removeEventListener("resize", updatePosition);
+ };
+ }, [open, updatePosition]);
+
+ useEffect(() => {
+ if (!open) return;
+ optionRefs.current[activeIndex]?.focus();
+ }, [open, activeIndex]);
+
+ useEffect(() => {
+ if (!open) return;
+ const handlePointer = (event: MouseEvent) => {
+ const target = event.target as Node;
+ if (buttonRef.current?.contains(target)) return;
+ if (popoverRef.current?.contains(target)) return;
+ setOpen(false);
+ };
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ setOpen(false);
+ buttonRef.current?.focus();
+ }
+ };
+ document.addEventListener("mousedown", handlePointer);
+ document.addEventListener("keydown", handleKey);
+ return () => {
+ document.removeEventListener("mousedown", handlePointer);
+ document.removeEventListener("keydown", handleKey);
+ };
+ }, [open]);
+
+ const choose = useCallback(
+ (next: number) => {
+ setLevel(next);
+ setOpen(false);
+ buttonRef.current?.focus();
+ },
+ [setLevel]
+ );
+
+ const onMenuKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
+ event.preventDefault();
+ const current = optionRefs.current.findIndex(
+ (el) => el === document.activeElement
+ );
+ if (current < 0) return;
+ const next =
+ event.key === "ArrowRight"
+ ? (current + 1) % options.length
+ : (current - 1 + options.length) % options.length;
+ optionRefs.current[next]?.focus();
+ };
+
+ if (!config.enabled) return null;
+
+ const buttonLabel = translate({
+ id: OPENAPI_SCHEMA_EXPANSION.BUTTON_LABEL,
+ message: "Schema expansion depth",
+ description: "Aria/title tooltip for the schema expansion icon button",
+ });
+ const menuLabel = translate({
+ id: OPENAPI_SCHEMA_EXPANSION.MENU_LABEL,
+ message: "Schema expansion depth options",
+ description: "Accessible label for the expansion options menu",
+ });
+ const allLabel = translate({
+ id: OPENAPI_SCHEMA_EXPANSION.ALL,
+ message: "All",
+ description: "Label for the expand-all option",
+ });
+
+ return (
+
+
+ {open && coords && (
+ {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ {options.map((value, index) => {
+ const isAll = value === ALL_VALUE;
+ const label = isAll ? allLabel : String(value);
+ const optionAriaLabel = isAll
+ ? allLabel
+ : translate(
+ {
+ id: OPENAPI_SCHEMA_EXPANSION.DEPTH_OPTION,
+ message: "Expand to depth {depth}",
+ description: "Accessible label for a depth option",
+ },
+ { depth: value }
+ );
+ const isActive = level === value;
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default SchemaExpansionControl;
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/styles.scss b/packages/docusaurus-theme-openapi-docs/src/theme/styles.scss
index 41bd5cb09..103ca7c4e 100644
--- a/packages/docusaurus-theme-openapi-docs/src/theme/styles.scss
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/styles.scss
@@ -32,6 +32,7 @@
/* Schema Styling */
@use "./ParamsItem/ParamsItem";
@use "./SchemaItem/SchemaItem";
+@use "./SchemaExpansion/SchemaExpansion";
/* Tabs Styling */
@use "./ApiTabs/ApiTabs";
@use "./DiscriminatorTabs/DiscriminatorTabs";
diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/translationIds.ts b/packages/docusaurus-theme-openapi-docs/src/theme/translationIds.ts
index 1f51a0b24..519893385 100644
--- a/packages/docusaurus-theme-openapi-docs/src/theme/translationIds.ts
+++ b/packages/docusaurus-theme-openapi-docs/src/theme/translationIds.ts
@@ -78,6 +78,13 @@ export const OPENAPI_SCHEMA = {
NO_SCHEMA: "theme.openapi.schema.noSchema",
};
+export const OPENAPI_SCHEMA_EXPANSION = {
+ BUTTON_LABEL: "theme.openapi.schema.expansion.button",
+ MENU_LABEL: "theme.openapi.schema.expansion.menu",
+ ALL: "theme.openapi.schema.expansion.all",
+ DEPTH_OPTION: "theme.openapi.schema.expansion.depthOption",
+};
+
export const OPENAPI_SCHEMA_ITEM = {
CHARACTERS: "theme.openapi.schemaItem.characters",
NON_EMPTY: "theme.openapi.schemaItem.nonEmpty",
diff --git a/packages/docusaurus-theme-openapi-docs/src/types.d.ts b/packages/docusaurus-theme-openapi-docs/src/types.d.ts
index 0afd06934..b68a237e9 100644
--- a/packages/docusaurus-theme-openapi-docs/src/types.d.ts
+++ b/packages/docusaurus-theme-openapi-docs/src/types.d.ts
@@ -34,6 +34,24 @@ export interface ThemeConfig {
* - `"include"`: Always send cookies, even for cross-origin requests
*/
requestCredentials?: "omit" | "same-origin" | "include";
+ /**
+ * Controls automatic expansion of nested schema trees in request/response
+ * documentation. Inspired by Redoc's `schemaExpansionLevel`.
+ *
+ * `default` applies whether or not `enabled` is `true` — set
+ * `{ default: 1 }` alone to auto-expand the first level on every page
+ * without rendering the depth control.
+ */
+ schemaExpansion?: {
+ /** Render an interactive depth control next to each schema header so readers can change the depth at view time. Defaults to `false`. */
+ enabled?: boolean;
+ /** Initial expansion depth applied on every page load. Use `"all"` to expand everything. Defaults to `0` (all collapsed). */
+ default?: number | "all";
+ /** Highest numeric depth offered by the UI control. Ignored when `enabled` is `false`. Defaults to `4`. */
+ max?: number;
+ /** Persist the reader's selected depth in `localStorage`. Only meaningful when `enabled` is `true`; defaults to `true` in that case. */
+ persist?: boolean;
+ };
};
}