From 302db8988782be479fd19ff39f1a3a37f091cf33 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 16:52:41 -0400 Subject: [PATCH 01/10] feat(theme): configurable schema expansion level (#1222) Adds an opt-in `themeConfig.api.schemaExpansion` option that controls the default expansion depth of nested request/response schema trees, plus an inline icon control next to each schema header that lets readers change the depth at view time. The selected depth is persisted in localStorage. - New `SchemaExpansionProvider` and `SchemaDepthProvider` contexts thread the active expansion level and per-node depth without prop-drilling through the recursive Schema renderer. - `SchemaNodeDetails` opens its `
` when `depth < level` and remounts on level change so the control feels responsive. - New `SchemaExpansionControl` renders an icon trigger inline with the Body/Schema headers; clicking opens a compact popover with depth options including "All". - Behavior is unchanged when `schemaExpansion` is unset. Demo: enables the option and adds an envelope-wrapped fixture under `demo/examples/tests/schemaExpansion.yaml` for manual exercise. Co-Authored-By: Claude Opus 4.7 --- demo/docusaurus.config.ts | 7 + demo/examples/tests/schemaExpansion.yaml | 140 ++++++++++++++++ .../src/theme/ApiItem/index.tsx | 45 +++--- .../src/theme/RequestSchema/index.tsx | 77 +++++---- .../src/theme/ResponseSchema/index.tsx | 30 ++-- .../src/theme/Schema/index.tsx | 22 ++- .../SchemaExpansion/_SchemaExpansion.scss | 110 +++++++++++++ .../src/theme/SchemaExpansion/context.tsx | 149 +++++++++++++++++ .../src/theme/SchemaExpansion/index.tsx | 152 ++++++++++++++++++ .../src/theme/styles.scss | 1 + .../src/types.d.ts | 14 ++ 11 files changed, 668 insertions(+), 79 deletions(-) create mode 100644 demo/examples/tests/schemaExpansion.yaml create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx create mode 100644 packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx diff --git a/demo/docusaurus.config.ts b/demo/docusaurus.config.ts index bc18529c6..15baf699a 100644 --- a/demo/docusaurus.config.ts +++ b/demo/docusaurus.config.ts @@ -271,6 +271,13 @@ const config: Config = { id: "announcementBar_2", content: "v5.0.0 is now available! Requires Docusaurus 3.10.0+", }, + api: { + schemaExpansion: { + enabled: true, + default: 1, + max: 4, + }, + }, } satisfies Preset.ThemeConfig, plugins: [ diff --git a/demo/examples/tests/schemaExpansion.yaml b/demo/examples/tests/schemaExpansion.yaml new file mode 100644 index 000000000..83824e14f --- /dev/null +++ b/demo/examples/tests/schemaExpansion.yaml @@ -0,0 +1,140 @@ +openapi: 3.0.0 +info: + title: Schema Expansion Demo API + version: 1.0.0 + description: | + Exercises the configurable schema expansion control. Endpoints below return + deeply nested envelope-wrapped payloads where the useful fields live a few + levels under `data`. The expansion pill control on each page should let + readers reveal those fields without manual clicking. + + Tracks: https://github.com/PaloAltoNetworks/docusaurus-openapi-docs/issues/1222 + +tags: + - name: expansion + description: Default schema expansion level demos + +paths: + /users/{id}: + get: + tags: + - expansion + summary: Get user (envelope-wrapped) + description: | + Wrapped in a typical `{ status, meta, data }` envelope where `data` + itself contains a nested `profile` object. With expansion level `1`, + `data` opens automatically; with level `2`, `profile` opens too. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: A wrapped user payload. + content: + application/json: + schema: + $ref: "#/components/schemas/UserEnvelope" + /users: + post: + tags: + - expansion + summary: Create user (nested request body) + description: | + The request body is also envelope-wrapped, exercising the expansion + control on the request side. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUserEnvelope" + responses: + "201": + description: Created. + content: + application/json: + schema: + $ref: "#/components/schemas/UserEnvelope" + +components: + schemas: + Status: + type: object + properties: + code: + type: integer + message: + type: string + + Meta: + type: object + properties: + requestId: + type: string + format: uuid + timestamp: + type: string + format: date-time + + Address: + type: object + properties: + street: + type: string + city: + type: string + country: + type: string + + Profile: + type: object + properties: + displayName: + type: string + bio: + type: string + address: + $ref: "#/components/schemas/Address" + + User: + type: object + properties: + id: + type: string + email: + type: string + format: email + profile: + $ref: "#/components/schemas/Profile" + + UserEnvelope: + type: object + properties: + status: + $ref: "#/components/schemas/Status" + meta: + $ref: "#/components/schemas/Meta" + data: + $ref: "#/components/schemas/User" + + CreateUserInput: + type: object + required: + - email + properties: + email: + type: string + format: email + profile: + $ref: "#/components/schemas/Profile" + + CreateUserEnvelope: + type: object + properties: + meta: + $ref: "#/components/schemas/Meta" + data: + $ref: "#/components/schemas/CreateUserInput" diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx index c7130d00c..7b7b1fff0 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/ApiItem/index.tsx @@ -20,6 +20,7 @@ import DocItemLayout from "@theme/ApiItem/Layout"; import CodeBlock from "@theme/CodeBlock"; import type { Props } from "@theme/DocItem"; import DocItemMetadata from "@theme/DocItem/Metadata"; +import { SchemaExpansionProvider } from "@theme/SchemaExpansion"; import SkeletonLoader from "@theme/SkeletonLoader"; import clsx from "clsx"; import type { @@ -177,18 +178,20 @@ export default function ApiItem(props: Props): JSX.Element { -
-
- -
-
- }> - {() => { - return ; - }} - + +
+
+ +
+
+ }> + {() => { + return ; + }} + +
-
+ @@ -200,16 +203,18 @@ export default function ApiItem(props: Props): JSX.Element { -
-
- -
-
- - {JSON.stringify(sample, null, 2)} - + +
+
+ +
+
+ + {JSON.stringify(sample, null, 2)} + +
-
+ diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/RequestSchema/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/RequestSchema/index.tsx index 48623434a..2b340a212 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/RequestSchema/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/RequestSchema/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"; @@ -77,24 +78,23 @@ const RequestSchemaComponent: React.FC = ({ title, body, style }) => { open={true} style={style} summary={ - <> - -

- {translate({ - id: OPENAPI_REQUEST.BODY_TITLE, - message: title, - })} - {body.required === true && ( - - {translate({ - id: OPENAPI_SCHEMA_ITEM.REQUIRED, - message: "required", - })} - - )} -

-
- + +

+ {translate({ + id: OPENAPI_REQUEST.BODY_TITLE, + message: title, + })} + {body.required === true && ( + + {translate({ + id: OPENAPI_SCHEMA_ITEM.REQUIRED, + message: "required", + })} + + )} +

+ +
} >
@@ -164,27 +164,26 @@ const RequestSchemaComponent: React.FC = ({ title, body, style }) => { open={true} style={style} summary={ - <> - -

- {translate({ - id: OPENAPI_REQUEST.BODY_TITLE, - message: title, - })} - {firstBody.type === "array" && ( - array - )} - {body.required && ( - - {translate({ - id: OPENAPI_SCHEMA_ITEM.REQUIRED, - message: "required", - })} - - )} -

-
- + +

+ {translate({ + id: OPENAPI_REQUEST.BODY_TITLE, + message: title, + })} + {firstBody.type === "array" && ( + array + )} + {body.required && ( + + {translate({ + id: OPENAPI_SCHEMA_ITEM.REQUIRED, + message: "required", + })} + + )} +

+ +
} >
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..674dc2a13 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss @@ -0,0 +1,110 @@ +/* ============================================================================ + * 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: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ifm-color-emphasis-600); + cursor: pointer; + 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: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 10; + 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); + cursor: pointer; + 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; + } +} 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..8d463a854 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx @@ -0,0 +1,149 @@ +/* ============================================================================ + * 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; + return { + enabled: raw.enabled ?? false, + defaultLevel: normalizeLevel(raw.default), + max: typeof raw.max === "number" && raw.max > 0 ? Math.floor(raw.max) : 4, + persist: raw.persist ?? true, + }; +} + +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..612bc0f96 --- /dev/null +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -0,0 +1,152 @@ +/* ============================================================================ + * 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, useRef, useState } from "react"; + +import { translate } from "@docusaurus/Translate"; +import clsx from "clsx"; + +import { useSchemaExpansion } from "./context"; + +export { + SchemaExpansionProvider, + SchemaDepthProvider, + useSchemaExpansion, + useSchemaDepth, + normalizeLevel, + SCHEMA_EXPANSION_STORAGE_KEY, +} from "./context"; + +const ExpandIcon: React.FC = () => ( + +); + +const SchemaExpansionControl: React.FC = () => { + const { config, level, setLevel } = useSchemaExpansion(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const buttonRef = useRef(null); + + useEffect(() => { + if (!open) return; + const handlePointer = (event: MouseEvent) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + 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] + ); + + if (!config.enabled) return null; + + const levels = Array.from({ length: config.max + 1 }, (_, i) => i); + const buttonLabel = translate({ + id: "theme.openapi.schema.expansion.button", + message: "Schema expansion depth", + description: "Aria/title for the schema expansion icon button", + }); + const allLabel = translate({ + id: "theme.openapi.schema.expansion.all", + message: "All", + description: "Label for the expand-all option", + }); + + return ( +
+ + {open && ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + {levels.map((n) => ( + + ))} + +
+ )} +
+ ); +}; + +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/types.d.ts b/packages/docusaurus-theme-openapi-docs/src/types.d.ts index 0afd06934..4faee625c 100644 --- a/packages/docusaurus-theme-openapi-docs/src/types.d.ts +++ b/packages/docusaurus-theme-openapi-docs/src/types.d.ts @@ -34,6 +34,20 @@ 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`. + */ + schemaExpansion?: { + /** Show an interactive control on the page so readers can change depth at view time. Defaults to `false`. */ + enabled?: boolean; + /** Initial expansion depth. Use `"all"` to expand everything. Defaults to `0` (all collapsed). */ + default?: number | "all"; + /** Maximum depth value offered by the UI control's pill buttons. Defaults to `4`. */ + max?: number; + /** Persist the reader's selected depth in `localStorage`. Defaults to `true` when `enabled` is `true`. */ + persist?: boolean; + }; }; } From b54a98e9223beb875ccf0ef0a8dfcf20c39b00e3 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 17:03:15 -0400 Subject: [PATCH 02/10] style(theme): remove pointer cursor on schema expansion control Defaults to the standard arrow cursor on hover for a less clicky feel. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/_SchemaExpansion.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss index 674dc2a13..be7de3e59 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss @@ -22,7 +22,6 @@ align-items: center; justify-content: center; color: var(--ifm-color-emphasis-600); - cursor: pointer; line-height: 0; opacity: 0.7; transition: @@ -74,7 +73,6 @@ font-size: 0.8rem; line-height: 1.4; color: var(--ifm-color-emphasis-800); - cursor: pointer; transition: background-color 0.12s ease, color 0.12s ease; From f6957ad4e64edcb41a13530084b3f522a156b017 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 17:17:54 -0400 Subject: [PATCH 03/10] style(theme): fix wide click target on schema expansion trigger Lock the trigger button to a 22x22 box so the hover background and click area match the icon, rather than stretching to the flex row's height. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/_SchemaExpansion.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss index be7de3e59..ecc448a69 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss @@ -17,7 +17,10 @@ background: transparent; border: none; border-radius: var(--ifm-global-radius); - padding: 4px; + padding: 0; + width: 22px; + height: 22px; + flex: 0 0 22px; display: inline-flex; align-items: center; justify-content: center; From 6d9008cc52a76e7ad98f2c4f796e1b7a4ba4afbc Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 17:35:17 -0400 Subject: [PATCH 04/10] fix(theme): render schema expansion popover in a portal When the surrounding
was collapsed, the absolutely-positioned popover got clipped by the details element. Render the popover via a portal to document.body with fixed positioning computed from the trigger's bounding rect, and reposition on scroll/resize. Co-Authored-By: Claude Opus 4.7 --- .../SchemaExpansion/_SchemaExpansion.scss | 6 +- .../src/theme/SchemaExpansion/index.tsx | 128 ++++++++++++------ 2 files changed, 85 insertions(+), 49 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss index ecc448a69..a869750b3 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss @@ -52,10 +52,8 @@ } .openapi-schema-expansion__popover { - position: absolute; - top: calc(100% + 4px); - right: 0; - z-index: 10; + position: fixed; + z-index: 1000; display: inline-flex; align-items: center; gap: 2px; diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx index 612bc0f96..df1eb6e6f 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -5,10 +5,17 @@ * LICENSE file in the root directory of this source tree. * ========================================================================== */ -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { translate } from "@docusaurus/Translate"; import clsx from "clsx"; +import { createPortal } from "react-dom"; import { useSchemaExpansion } from "./context"; @@ -42,16 +49,39 @@ const ExpandIcon: React.FC = () => ( const SchemaExpansionControl: React.FC = () => { const { config, level, setLevel } = useSchemaExpansion(); const [open, setOpen] = useState(false); - const containerRef = useRef(null); + const [coords, setCoords] = useState<{ top: number; right: number } | null>( + null + ); const buttonRef = useRef(null); + const popoverRef = useRef(null); + + const updatePosition = useCallback(() => { + if (!buttonRef.current) return; + const rect = buttonRef.current.getBoundingClientRect(); + setCoords({ + top: rect.bottom + 4, + right: window.innerWidth - rect.right, + }); + }, []); + + useLayoutEffect(() => { + if (!open) return; + updatePosition(); + window.addEventListener("scroll", updatePosition, true); + window.addEventListener("resize", updatePosition); + return () => { + window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("resize", updatePosition); + }; + }, [open, updatePosition]); useEffect(() => { if (!open) return; const handlePointer = (event: MouseEvent) => { - if (!containerRef.current) return; - if (!containerRef.current.contains(event.target as Node)) { - setOpen(false); - } + 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") { @@ -90,8 +120,52 @@ const SchemaExpansionControl: React.FC = () => { description: "Label for the expand-all option", }); + const popover = + open && coords && typeof document !== "undefined" + ? createPortal( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + {levels.map((n) => ( + + ))} + +
, + document.body + ) + : null; + return ( -
+ - {open && ( -
{ - event.preventDefault(); - event.stopPropagation(); - }} - > - {levels.map((n) => ( - - ))} - -
- )} -
+ {popover} + ); }; From d237ff9e8df910d329148df33aa78eba73002524 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 17:36:34 -0400 Subject: [PATCH 05/10] fix(theme): use fixed positioning instead of portal for popover Avoid pulling in @types/react-dom by computing the trigger's bounding rect and rendering the popover with position: fixed in-place. Fixed positioning still escapes the surrounding details overflow, so the menu is no longer clipped when the schema is collapsed. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/index.tsx | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx index df1eb6e6f..ff79ab42c 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -15,7 +15,6 @@ import React, { import { translate } from "@docusaurus/Translate"; import clsx from "clsx"; -import { createPortal } from "react-dom"; import { useSchemaExpansion } from "./context"; @@ -56,7 +55,7 @@ const SchemaExpansionControl: React.FC = () => { const popoverRef = useRef(null); const updatePosition = useCallback(() => { - if (!buttonRef.current) return; + if (!buttonRef.current || typeof window === "undefined") return; const rect = buttonRef.current.getBoundingClientRect(); setCoords({ top: rect.bottom + 4, @@ -120,50 +119,6 @@ const SchemaExpansionControl: React.FC = () => { description: "Label for the expand-all option", }); - const popover = - open && coords && typeof document !== "undefined" - ? createPortal( -
{ - event.preventDefault(); - event.stopPropagation(); - }} - > - {levels.map((n) => ( - - ))} - -
, - document.body - ) - : null; - return ( - {popover} + {open && coords && ( +
{ + event.preventDefault(); + event.stopPropagation(); + }} + > + {levels.map((n) => ( + + ))} + +
+ )}
); }; From a2a75ca946df5d009e3c5b17bae651b6b81d9583 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Mon, 4 May 2026 17:44:08 -0400 Subject: [PATCH 06/10] style(theme): keep REQUIRED label inline with schema title When the parent summary became a flex row, the h3/strong holding the title and the REQUIRED span fell back to block layout and wrapped. Make the title element itself an inline-flex with a small gap so the title and the badge stay on one line and align vertically. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/_SchemaExpansion.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss index a869750b3..baa7c643a 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/_SchemaExpansion.scss @@ -105,5 +105,9 @@ > h3, > strong { margin-bottom: 0; + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.4rem; } } From a1a014937087e86d3b54b4e1b4697161cd2b65a9 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 5 May 2026 10:23:34 -0400 Subject: [PATCH 07/10] docs(theme): clarify tooltip on schema expansion control Switch the aria-label / title from "Schema expansion depth" to a more descriptive "Set how deep schemas auto-expand" so hovering the icon explains what the control does. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx index ff79ab42c..aeb082d33 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -110,8 +110,8 @@ const SchemaExpansionControl: React.FC = () => { const levels = Array.from({ length: config.max + 1 }, (_, i) => i); const buttonLabel = translate({ id: "theme.openapi.schema.expansion.button", - message: "Schema expansion depth", - description: "Aria/title for the schema expansion icon button", + message: "Set how deep schemas auto-expand", + description: "Aria/title tooltip for the schema expansion icon button", }); const allLabel = translate({ id: "theme.openapi.schema.expansion.all", From 3a18715190c285f5de2d8cc8cfdbf62f4c0bc1fd Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 5 May 2026 10:24:34 -0400 Subject: [PATCH 08/10] fix(theme): close schema expansion popover on scroll Repositioning the popover during scroll lagged behind the trigger and felt detached. Close the popover instead so it always appears anchored at the moment of opening. Also revert the tooltip text to its original "Schema expansion depth". Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/index.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx index aeb082d33..384b98d69 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -66,10 +66,11 @@ const SchemaExpansionControl: React.FC = () => { useLayoutEffect(() => { if (!open) return; updatePosition(); - window.addEventListener("scroll", updatePosition, true); + const handleScroll = () => setOpen(false); + window.addEventListener("scroll", handleScroll, true); window.addEventListener("resize", updatePosition); return () => { - window.removeEventListener("scroll", updatePosition, true); + window.removeEventListener("scroll", handleScroll, true); window.removeEventListener("resize", updatePosition); }; }, [open, updatePosition]); @@ -110,7 +111,7 @@ const SchemaExpansionControl: React.FC = () => { const levels = Array.from({ length: config.max + 1 }, (_, i) => i); const buttonLabel = translate({ id: "theme.openapi.schema.expansion.button", - message: "Set how deep schemas auto-expand", + message: "Schema expansion depth", description: "Aria/title tooltip for the schema expansion icon button", }); const allLabel = translate({ From cdc2ed7aa5cb1ff128eef50b905bd132f8dc5f14 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 5 May 2026 10:27:53 -0400 Subject: [PATCH 09/10] chore(theme): polish a11y, i18n, and BEM for schema expansion control - Centralize translation IDs under OPENAPI_SCHEMA_EXPANSION in translationIds.ts so they can be discovered alongside other theme strings and overridden via i18n catalogs. - aria-haspopup="menu" (was "true") + aria-controls + popover id + aria-label on the menu so assistive tech announces it as a menu. - Move focus to the active option when the menu opens; arrow-key navigation between options; ESC stops propagation so it doesn't also collapse a parent details element. - Per-option aria-label "Expand to depth N" via translate() so screen readers get a full description instead of just a number. - Class names already follow BEM (block, __element, --modifier). Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/index.tsx | 113 +++++++++++++----- .../src/theme/translationIds.ts | 7 ++ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx index 384b98d69..abd518623 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/index.tsx @@ -8,12 +8,15 @@ 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"; @@ -27,6 +30,8 @@ export { SCHEMA_EXPANSION_STORAGE_KEY, } from "./context"; +const ALL_VALUE = Number.POSITIVE_INFINITY; + const ExpandIcon: React.FC = () => ( )} 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", From 02cfa64bf2bd80add41b13386b72714f4208da58 Mon Sep 17 00:00:00 2001 From: Steven Serrata Date: Tue, 5 May 2026 10:54:48 -0400 Subject: [PATCH 10/10] fix(theme): apply default expansion depth even when control is hidden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `themeConfig.api.schemaExpansion.default` now reliably auto-expands to the configured depth on every page load when `enabled` is false — the provider previously kept reading from localStorage in that case, so a stale value from a prior session where the control was enabled could shadow the new default. Persistence is now implicitly off whenever the control is hidden. Also clarify the docstrings so it's discoverable that you can set just `{ default: 1 }` to get auto-expansion without rendering the UI. Co-Authored-By: Claude Opus 4.7 --- .../src/theme/SchemaExpansion/context.tsx | 9 +++++++-- .../docusaurus-theme-openapi-docs/src/types.d.ts | 12 ++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx index 8d463a854..a27176288 100644 --- a/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx +++ b/packages/docusaurus-theme-openapi-docs/src/theme/SchemaExpansion/context.tsx @@ -61,11 +61,16 @@ function readConfig( ): SchemaExpansionConfig { const raw = themeConfig?.api?.schemaExpansion; if (!raw) return DEFAULT_CONFIG; + const enabled = raw.enabled ?? false; return { - enabled: raw.enabled ?? false, + enabled, defaultLevel: normalizeLevel(raw.default), max: typeof raw.max === "number" && raw.max > 0 ? Math.floor(raw.max) : 4, - persist: raw.persist ?? true, + // 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, }; } diff --git a/packages/docusaurus-theme-openapi-docs/src/types.d.ts b/packages/docusaurus-theme-openapi-docs/src/types.d.ts index 4faee625c..b68a237e9 100644 --- a/packages/docusaurus-theme-openapi-docs/src/types.d.ts +++ b/packages/docusaurus-theme-openapi-docs/src/types.d.ts @@ -37,15 +37,19 @@ export interface ThemeConfig { /** * 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?: { - /** Show an interactive control on the page so readers can change depth at view time. Defaults to `false`. */ + /** 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. Use `"all"` to expand everything. Defaults to `0` (all collapsed). */ + /** Initial expansion depth applied on every page load. Use `"all"` to expand everything. Defaults to `0` (all collapsed). */ default?: number | "all"; - /** Maximum depth value offered by the UI control's pill buttons. Defaults to `4`. */ + /** 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`. Defaults to `true` when `enabled` is `true`. */ + /** Persist the reader's selected depth in `localStorage`. Only meaningful when `enabled` is `true`; defaults to `true` in that case. */ persist?: boolean; }; };