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..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 && ( + + )} + + ); +}; + +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; + }; }; }