@djangocfg/ui-tools 2.1.287 → 2.1.289
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{DocsLayout-ERETJLLV.mjs → DocsLayout-TKJQ5W5E.mjs} +848 -266
- package/dist/DocsLayout-TKJQ5W5E.mjs.map +1 -0
- package/dist/{DocsLayout-BCVU6TTX.cjs → DocsLayout-YDR7DSMM.cjs} +843 -261
- package/dist/DocsLayout-YDR7DSMM.cjs.map +1 -0
- package/dist/index.cjs +2 -2
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.mjs +2 -2
- package/package.json +9 -6
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +27 -0
- package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +326 -54
- package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +7 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +32 -9
- package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +348 -120
- package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
- package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
- package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
- package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
- package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
- package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +127 -7
- package/src/tools/OpenapiViewer/types.ts +36 -1
- package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
- package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
- package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -262,7 +262,7 @@ function OpenapiLoadingFallback() {
|
|
|
262
262
|
}
|
|
263
263
|
chunkWGEGR3DF_cjs.__name(OpenapiLoadingFallback, "OpenapiLoadingFallback");
|
|
264
264
|
var LazyDocsLayout = createLazyComponent(
|
|
265
|
-
() => import('./DocsLayout-
|
|
265
|
+
() => import('./DocsLayout-YDR7DSMM.cjs').then((mod) => ({ default: mod.DocsLayout })),
|
|
266
266
|
{
|
|
267
267
|
displayName: "LazyDocsLayout",
|
|
268
268
|
fallback: /* @__PURE__ */ jsxRuntime.jsx(OpenapiLoadingFallback, {})
|
|
@@ -417,7 +417,7 @@ function LottiePlayer(props) {
|
|
|
417
417
|
}
|
|
418
418
|
chunkWGEGR3DF_cjs.__name(LottiePlayer, "LottiePlayer");
|
|
419
419
|
var DocsLayout = React.lazy(
|
|
420
|
-
() => import('./DocsLayout-
|
|
420
|
+
() => import('./DocsLayout-YDR7DSMM.cjs').then((mod) => ({ default: mod.DocsLayout }))
|
|
421
421
|
);
|
|
422
422
|
var LoadingFallback7 = /* @__PURE__ */ chunkWGEGR3DF_cjs.__name(() => /* @__PURE__ */ jsxRuntime.jsx("div", { className: "flex items-center justify-center min-h-[400px]", children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "text-muted-foreground", children: "Loading API Playground..." }) }), "LoadingFallback");
|
|
423
423
|
var Playground = /* @__PURE__ */ chunkWGEGR3DF_cjs.__name(({ config }) => {
|
package/dist/index.d.cts
CHANGED
|
@@ -361,6 +361,22 @@ interface PlaygroundConfig {
|
|
|
361
361
|
* a spinner instead of "no keys yet". */
|
|
362
362
|
apiKeys?: ApiKey[];
|
|
363
363
|
apiKeysLoading?: boolean;
|
|
364
|
+
/** How multiple schemas are presented in the sidebar.
|
|
365
|
+
* - ``'selector'`` (default): a Combobox switches between schemas, the
|
|
366
|
+
* docs column shows endpoints of the active schema only.
|
|
367
|
+
* - ``'sections'``: the Combobox is hidden and every schema becomes a
|
|
368
|
+
* top-level heading in the sidebar, with endpoints of all schemas
|
|
369
|
+
* rendered back-to-back in the docs column. Scrollspy picks the
|
|
370
|
+
* active schema based on what's visible. */
|
|
371
|
+
schemaGrouping?: 'selector' | 'sections';
|
|
372
|
+
/** Optional URL-hash sync. When enabled, the viewer reads/writes
|
|
373
|
+
* ``#<schemaId>/<anchor>`` on the browser location. Falsy value (the
|
|
374
|
+
* default) keeps the viewer hash-free. Set to an object with
|
|
375
|
+
* ``{ enabled: true }`` to opt in; future fields (e.g. a custom
|
|
376
|
+
* adapter) stay backwards compatible. */
|
|
377
|
+
urlSync?: boolean | {
|
|
378
|
+
enabled: boolean;
|
|
379
|
+
};
|
|
364
380
|
}
|
|
365
381
|
|
|
366
382
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -361,6 +361,22 @@ interface PlaygroundConfig {
|
|
|
361
361
|
* a spinner instead of "no keys yet". */
|
|
362
362
|
apiKeys?: ApiKey[];
|
|
363
363
|
apiKeysLoading?: boolean;
|
|
364
|
+
/** How multiple schemas are presented in the sidebar.
|
|
365
|
+
* - ``'selector'`` (default): a Combobox switches between schemas, the
|
|
366
|
+
* docs column shows endpoints of the active schema only.
|
|
367
|
+
* - ``'sections'``: the Combobox is hidden and every schema becomes a
|
|
368
|
+
* top-level heading in the sidebar, with endpoints of all schemas
|
|
369
|
+
* rendered back-to-back in the docs column. Scrollspy picks the
|
|
370
|
+
* active schema based on what's visible. */
|
|
371
|
+
schemaGrouping?: 'selector' | 'sections';
|
|
372
|
+
/** Optional URL-hash sync. When enabled, the viewer reads/writes
|
|
373
|
+
* ``#<schemaId>/<anchor>`` on the browser location. Falsy value (the
|
|
374
|
+
* default) keeps the viewer hash-free. Set to an object with
|
|
375
|
+
* ``{ enabled: true }`` to opt in; future fields (e.g. a custom
|
|
376
|
+
* adapter) stay backwards compatible. */
|
|
377
|
+
urlSync?: boolean | {
|
|
378
|
+
enabled: boolean;
|
|
379
|
+
};
|
|
364
380
|
}
|
|
365
381
|
|
|
366
382
|
/**
|
package/dist/index.mjs
CHANGED
|
@@ -237,7 +237,7 @@ function OpenapiLoadingFallback() {
|
|
|
237
237
|
}
|
|
238
238
|
__name(OpenapiLoadingFallback, "OpenapiLoadingFallback");
|
|
239
239
|
var LazyDocsLayout = createLazyComponent(
|
|
240
|
-
() => import('./DocsLayout-
|
|
240
|
+
() => import('./DocsLayout-TKJQ5W5E.mjs').then((mod) => ({ default: mod.DocsLayout })),
|
|
241
241
|
{
|
|
242
242
|
displayName: "LazyDocsLayout",
|
|
243
243
|
fallback: /* @__PURE__ */ jsx(OpenapiLoadingFallback, {})
|
|
@@ -392,7 +392,7 @@ function LottiePlayer(props) {
|
|
|
392
392
|
}
|
|
393
393
|
__name(LottiePlayer, "LottiePlayer");
|
|
394
394
|
var DocsLayout = lazy(
|
|
395
|
-
() => import('./DocsLayout-
|
|
395
|
+
() => import('./DocsLayout-TKJQ5W5E.mjs').then((mod) => ({ default: mod.DocsLayout }))
|
|
396
396
|
);
|
|
397
397
|
var LoadingFallback7 = /* @__PURE__ */ __name(() => /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center min-h-[400px]", children: /* @__PURE__ */ jsx("div", { className: "text-muted-foreground", children: "Loading API Playground..." }) }), "LoadingFallback");
|
|
398
398
|
var Playground = /* @__PURE__ */ __name(({ config }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.289",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -90,9 +90,10 @@
|
|
|
90
90
|
"check": "tsc --noEmit"
|
|
91
91
|
},
|
|
92
92
|
"peerDependencies": {
|
|
93
|
-
"@djangocfg/i18n": "^2.1.
|
|
94
|
-
"@djangocfg/ui-core": "^2.1.
|
|
93
|
+
"@djangocfg/i18n": "^2.1.289",
|
|
94
|
+
"@djangocfg/ui-core": "^2.1.289",
|
|
95
95
|
"consola": "^3.4.2",
|
|
96
|
+
"lodash-es": "^4.18.1",
|
|
96
97
|
"lucide-react": "^0.545.0",
|
|
97
98
|
"react": "^19.1.0",
|
|
98
99
|
"react-dom": "^19.1.0",
|
|
@@ -133,14 +134,16 @@
|
|
|
133
134
|
"@maplibre/maplibre-gl-geocoder": "^1.7.0"
|
|
134
135
|
},
|
|
135
136
|
"devDependencies": {
|
|
136
|
-
"@djangocfg/i18n": "^2.1.
|
|
137
|
+
"@djangocfg/i18n": "^2.1.289",
|
|
137
138
|
"@djangocfg/playground": "workspace:*",
|
|
138
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
139
|
-
"@djangocfg/ui-core": "^2.1.
|
|
139
|
+
"@djangocfg/typescript-config": "^2.1.289",
|
|
140
|
+
"@djangocfg/ui-core": "^2.1.289",
|
|
141
|
+
"@types/lodash-es": "^4.17.12",
|
|
140
142
|
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
|
|
141
143
|
"@types/node": "^24.7.2",
|
|
142
144
|
"@types/react": "^19.1.0",
|
|
143
145
|
"@types/react-dom": "^19.1.0",
|
|
146
|
+
"lodash-es": "^4.18.1",
|
|
144
147
|
"lucide-react": "^0.545.0",
|
|
145
148
|
"react": "^19.1.0",
|
|
146
149
|
"react-dom": "^19.1.0",
|
|
@@ -124,6 +124,33 @@ export const MultipleSchemas = () => {
|
|
|
124
124
|
);
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Sections grouping — all schemas rendered as top-level headings in the
|
|
129
|
+
* sidebar, with every endpoint laid out on the same page. URL hash sync
|
|
130
|
+
* is enabled so scrolling updates ``#<schemaId>/<anchor>``.
|
|
131
|
+
*/
|
|
132
|
+
export const SectionsGrouping = () => {
|
|
133
|
+
const [urlSync] = useSelect('urlSync', {
|
|
134
|
+
options: ['on', 'off'] as const,
|
|
135
|
+
defaultValue: 'on',
|
|
136
|
+
label: 'URL hash sync',
|
|
137
|
+
description: 'Writes #<schemaId>/<anchor> as you scroll',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const config: PlaygroundConfig = {
|
|
141
|
+
schemas: [...MULTI_SCHEMAS],
|
|
142
|
+
defaultSchemaId: 'petstore',
|
|
143
|
+
schemaGrouping: 'sections',
|
|
144
|
+
urlSync: urlSync === 'on',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div className="min-h-[600px]">
|
|
149
|
+
<Playground config={config} />
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
127
154
|
/**
|
|
128
155
|
* Many schemas (7+) — tests Combobox with a long list.
|
|
129
156
|
* Simulates a real setup like cmdop with machines, terminal, skills, etc.
|
|
@@ -2,17 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
4
4
|
|
|
5
|
-
import type { ApiEndpoint, OpenApiInfo, OpenApiSchema } from '../../types';
|
|
5
|
+
import type { ApiEndpoint, LoadedSchemaEntry, OpenApiInfo, OpenApiSchema } from '../../types';
|
|
6
|
+
import {
|
|
7
|
+
getScrollParent,
|
|
8
|
+
getScrollTop,
|
|
9
|
+
getTargetTop,
|
|
10
|
+
getViewportHeight,
|
|
11
|
+
scrollTargetTo,
|
|
12
|
+
type ScrollTarget,
|
|
13
|
+
} from '../../utils/scrollParent';
|
|
6
14
|
import { deduplicateEndpoints } from '../../utils/versionManager';
|
|
7
15
|
import { ApiIntroSection } from './ApiIntroSection';
|
|
8
16
|
import { EndpointDoc } from './EndpointDoc';
|
|
9
|
-
import {
|
|
17
|
+
import { SchemaCopyMenu } from './SchemaCopyMenu';
|
|
10
18
|
|
|
11
19
|
export interface DocsViewHandle {
|
|
12
20
|
scrollToAnchor: (anchor: string) => void;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
|
|
23
|
+
// ─── Props ───────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
interface SelectorProps {
|
|
26
|
+
grouping?: 'selector';
|
|
16
27
|
info: OpenApiInfo | null;
|
|
17
28
|
rawSchema: OpenApiSchema | null;
|
|
18
29
|
resolvedBaseUrl?: string;
|
|
@@ -20,48 +31,169 @@ interface DocsViewProps {
|
|
|
20
31
|
selectedVersion: string;
|
|
21
32
|
loadedEndpoint: ApiEndpoint | null;
|
|
22
33
|
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
23
|
-
onActiveChange: (anchor: string | null) => void;
|
|
34
|
+
onActiveChange: (anchor: string | null, schemaId: string | null) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SectionsProps {
|
|
38
|
+
grouping: 'sections';
|
|
39
|
+
/** Per-schema data (info + endpoints). Rendered in order. */
|
|
40
|
+
schemasData: LoadedSchemaEntry[];
|
|
41
|
+
selectedVersion: string;
|
|
42
|
+
loadedEndpoint: ApiEndpoint | null;
|
|
43
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
44
|
+
onActiveChange: (anchor: string | null, schemaId: string | null) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type DocsViewProps = SelectorProps | SectionsProps;
|
|
48
|
+
|
|
49
|
+
// ─── View-model types ────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface EndpointRow {
|
|
52
|
+
key: string;
|
|
53
|
+
endpoint: ApiEndpoint;
|
|
54
|
+
isLoaded: boolean;
|
|
55
|
+
schemaId: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type SectionState =
|
|
59
|
+
| { kind: 'ready'; rows: EndpointRow[] }
|
|
60
|
+
| { kind: 'loading' }
|
|
61
|
+
| { kind: 'error'; message: string }
|
|
62
|
+
| { kind: 'empty' };
|
|
63
|
+
|
|
64
|
+
interface SchemaSectionVM {
|
|
65
|
+
schemaId: string;
|
|
66
|
+
title: string;
|
|
67
|
+
version: string | null;
|
|
68
|
+
description: string | null;
|
|
69
|
+
state: SectionState;
|
|
70
|
+
/** Copy-for-AI payload. ``null`` when the section is still loading
|
|
71
|
+
* or failed — the dropdown stays disabled. */
|
|
72
|
+
rawSchema: OpenApiSchema | null;
|
|
73
|
+
baseUrl: string | undefined;
|
|
74
|
+
allEndpoints: ApiEndpoint[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
/** Pixel offset from the top of the scroll container where the viewer
|
|
80
|
+
* should "park" sections. Reads ``--navbar-height`` for back-compat
|
|
81
|
+
* with pages that already set it; defaults to ``0`` for embedded /
|
|
82
|
+
* no-navbar setups (the common case when hosted in a shell). */
|
|
83
|
+
const readNavbarOffset = () => {
|
|
84
|
+
if (typeof document === 'undefined') return 0;
|
|
85
|
+
const raw = getComputedStyle(document.documentElement).getPropertyValue('--navbar-height');
|
|
86
|
+
const parsed = parseInt(raw || '', 10);
|
|
87
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const isSameEndpoint = (a: ApiEndpoint | null, b: ApiEndpoint) =>
|
|
91
|
+
a !== null && a.method === b.method && a.path === b.path;
|
|
92
|
+
|
|
93
|
+
function buildEndpointRow(
|
|
94
|
+
ep: ApiEndpoint,
|
|
95
|
+
loadedEndpoint: ApiEndpoint | null,
|
|
96
|
+
schemaId: string | null,
|
|
97
|
+
): EndpointRow {
|
|
98
|
+
const keySchema = schemaId ? `${schemaId}-` : '';
|
|
99
|
+
return {
|
|
100
|
+
key: `${keySchema}${ep.method}-${ep.path}`,
|
|
101
|
+
endpoint: ep,
|
|
102
|
+
isLoaded: isSameEndpoint(loadedEndpoint, ep),
|
|
103
|
+
schemaId,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildSchemaSectionVM(
|
|
108
|
+
entry: LoadedSchemaEntry,
|
|
109
|
+
selectedVersion: string,
|
|
110
|
+
loadedEndpoint: ApiEndpoint | null,
|
|
111
|
+
): SchemaSectionVM {
|
|
112
|
+
const title = entry.info?.title ?? entry.source.name;
|
|
113
|
+
const version = entry.info?.version ?? null;
|
|
114
|
+
const description = entry.info?.description ?? null;
|
|
115
|
+
|
|
116
|
+
let state: SectionState;
|
|
117
|
+
if (entry.loading) {
|
|
118
|
+
state = { kind: 'loading' };
|
|
119
|
+
} else if (entry.error) {
|
|
120
|
+
state = { kind: 'error', message: entry.error };
|
|
121
|
+
} else {
|
|
122
|
+
const visible = deduplicateEndpoints(entry.endpoints, selectedVersion);
|
|
123
|
+
state = visible.length === 0
|
|
124
|
+
? { kind: 'empty' }
|
|
125
|
+
: {
|
|
126
|
+
kind: 'ready',
|
|
127
|
+
rows: visible.map((ep) => buildEndpointRow(ep, loadedEndpoint, entry.source.id)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
schemaId: entry.source.id,
|
|
133
|
+
title,
|
|
134
|
+
version,
|
|
135
|
+
description,
|
|
136
|
+
state,
|
|
137
|
+
rawSchema: entry.rawSchema,
|
|
138
|
+
baseUrl: entry.resolvedBaseUrl,
|
|
139
|
+
allEndpoints: entry.endpoints,
|
|
140
|
+
};
|
|
24
141
|
}
|
|
25
142
|
|
|
143
|
+
// ─── Component ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
26
145
|
export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function DocsView(
|
|
27
|
-
|
|
28
|
-
info,
|
|
29
|
-
rawSchema,
|
|
30
|
-
resolvedBaseUrl,
|
|
31
|
-
endpoints,
|
|
32
|
-
selectedVersion,
|
|
33
|
-
loadedEndpoint,
|
|
34
|
-
onTryEndpoint,
|
|
35
|
-
onActiveChange,
|
|
36
|
-
},
|
|
146
|
+
props,
|
|
37
147
|
ref,
|
|
38
148
|
) {
|
|
39
149
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
|
150
|
+
const scrollTargetRef = useRef<ScrollTarget | null>(null);
|
|
151
|
+
const { onActiveChange } = props;
|
|
40
152
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const root = scrollRef.current;
|
|
50
|
-
if (!root) return;
|
|
51
|
-
const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
|
|
52
|
-
if (!el) return;
|
|
53
|
-
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
153
|
+
// Resolve the real scroll container once the ref is attached. In
|
|
154
|
+
// standalone pages that's ``window``; inside an ``overflow-auto``
|
|
155
|
+
// shell (dev playground, modal) it's the wrapping DIV.
|
|
156
|
+
const ensureScrollTarget = useCallback((): ScrollTarget | null => {
|
|
157
|
+
if (scrollTargetRef.current) return scrollTargetRef.current;
|
|
158
|
+
if (!scrollRef.current) return null;
|
|
159
|
+
scrollTargetRef.current = getScrollParent(scrollRef.current);
|
|
160
|
+
return scrollTargetRef.current;
|
|
54
161
|
}, []);
|
|
55
162
|
|
|
163
|
+
// Scroll a given section into view. Works against whichever ancestor
|
|
164
|
+
// actually scrolls — window for standalone, the overflow-auto parent
|
|
165
|
+
// for embedded layouts — so callers don't need to know the difference.
|
|
166
|
+
const scrollToAnchor = useCallback(
|
|
167
|
+
(anchor: string) => {
|
|
168
|
+
const root = scrollRef.current;
|
|
169
|
+
if (!root) return;
|
|
170
|
+
const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
|
|
171
|
+
if (!el) return;
|
|
172
|
+
const target = ensureScrollTarget();
|
|
173
|
+
if (!target) return;
|
|
174
|
+
const navbar = readNavbarOffset();
|
|
175
|
+
const top =
|
|
176
|
+
el.getBoundingClientRect().top -
|
|
177
|
+
getTargetTop(target) +
|
|
178
|
+
getScrollTop(target) -
|
|
179
|
+
navbar -
|
|
180
|
+
8;
|
|
181
|
+
scrollTargetTo(target, top);
|
|
182
|
+
},
|
|
183
|
+
[ensureScrollTarget],
|
|
184
|
+
);
|
|
185
|
+
|
|
56
186
|
React.useImperativeHandle(ref, () => ({ scrollToAnchor }), [scrollToAnchor]);
|
|
57
187
|
|
|
58
188
|
// Scrollspy: pick the topmost endpoint section whose top is near the
|
|
59
|
-
// upper
|
|
60
|
-
//
|
|
61
|
-
//
|
|
189
|
+
// upper quarter of the viewport. Listens on the real scroll container
|
|
190
|
+
// (see ``ensureScrollTarget``) because ``scroll`` events on a nested
|
|
191
|
+
// overflow:auto element do NOT bubble up to window.
|
|
62
192
|
useEffect(() => {
|
|
63
193
|
const root = scrollRef.current;
|
|
64
194
|
if (!root) return;
|
|
195
|
+
const target = ensureScrollTarget();
|
|
196
|
+
if (!target) return;
|
|
65
197
|
|
|
66
198
|
let rafId = 0;
|
|
67
199
|
let lastActive: string | null = null;
|
|
@@ -70,20 +202,22 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
70
202
|
rafId = 0;
|
|
71
203
|
const sections = root.querySelectorAll<HTMLElement>('[data-endpoint-anchor]');
|
|
72
204
|
if (sections.length === 0) return;
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
205
|
+
const navbar = readNavbarOffset();
|
|
206
|
+
const viewportTop = getTargetTop(target);
|
|
207
|
+
const threshold = viewportTop + navbar + getViewportHeight(target) * 0.25;
|
|
208
|
+
let active: HTMLElement | null = null;
|
|
76
209
|
for (const s of Array.from(sections)) {
|
|
77
210
|
const top = s.getBoundingClientRect().top;
|
|
78
211
|
if (top <= threshold) {
|
|
79
|
-
active = s
|
|
212
|
+
active = s;
|
|
80
213
|
} else {
|
|
81
214
|
break;
|
|
82
215
|
}
|
|
83
216
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
217
|
+
const anchor = active?.dataset.endpointAnchor ?? null;
|
|
218
|
+
if (anchor !== lastActive) {
|
|
219
|
+
lastActive = anchor;
|
|
220
|
+
onActiveChange(anchor, active?.dataset.schemaId || null);
|
|
87
221
|
}
|
|
88
222
|
};
|
|
89
223
|
|
|
@@ -93,15 +227,49 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
93
227
|
};
|
|
94
228
|
|
|
95
229
|
compute();
|
|
96
|
-
|
|
230
|
+
target.addEventListener('scroll', onScroll, { passive: true });
|
|
231
|
+
// Resize always bubbles to window — listen there regardless of target.
|
|
232
|
+
window.addEventListener('resize', onScroll, { passive: true });
|
|
97
233
|
return () => {
|
|
98
|
-
|
|
234
|
+
target.removeEventListener('scroll', onScroll);
|
|
235
|
+
window.removeEventListener('resize', onScroll);
|
|
99
236
|
if (rafId) cancelAnimationFrame(rafId);
|
|
100
237
|
};
|
|
101
|
-
}, [
|
|
238
|
+
}, [onActiveChange, ensureScrollTarget, props]);
|
|
239
|
+
|
|
240
|
+
if (props.grouping === 'sections') {
|
|
241
|
+
return <SectionsBody scrollRef={scrollRef} {...props} />;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return <SelectorBody scrollRef={scrollRef} {...props} />;
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ─── Selector body (single active schema) ────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function SelectorBody({
|
|
250
|
+
scrollRef,
|
|
251
|
+
info,
|
|
252
|
+
rawSchema,
|
|
253
|
+
resolvedBaseUrl,
|
|
254
|
+
endpoints,
|
|
255
|
+
selectedVersion,
|
|
256
|
+
loadedEndpoint,
|
|
257
|
+
onTryEndpoint,
|
|
258
|
+
}: SelectorProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
|
|
259
|
+
const visibleEndpoints = useMemo(
|
|
260
|
+
() => deduplicateEndpoints(endpoints, selectedVersion),
|
|
261
|
+
[endpoints, selectedVersion],
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const rows = useMemo<EndpointRow[]>(
|
|
265
|
+
() => visibleEndpoints.map((ep) => buildEndpointRow(ep, loadedEndpoint, ep.schemaId ?? null)),
|
|
266
|
+
[visibleEndpoints, loadedEndpoint],
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const isEmpty = rows.length === 0;
|
|
102
270
|
|
|
103
271
|
return (
|
|
104
|
-
<div ref={scrollRef}
|
|
272
|
+
<div ref={scrollRef}>
|
|
105
273
|
<div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12">
|
|
106
274
|
{info && (
|
|
107
275
|
<ApiIntroSection
|
|
@@ -111,27 +279,131 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
|
|
|
111
279
|
resolvedBaseUrl={resolvedBaseUrl}
|
|
112
280
|
/>
|
|
113
281
|
)}
|
|
114
|
-
{
|
|
282
|
+
{isEmpty ? (
|
|
115
283
|
<div className="py-16 text-center text-sm text-muted-foreground">
|
|
116
284
|
No endpoints to display.
|
|
117
285
|
</div>
|
|
118
286
|
) : (
|
|
119
287
|
<div className="divide-y divide-border/60">
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
/>
|
|
130
|
-
);
|
|
131
|
-
})}
|
|
288
|
+
{rows.map((row) => (
|
|
289
|
+
<EndpointDoc
|
|
290
|
+
key={row.key}
|
|
291
|
+
endpoint={row.endpoint}
|
|
292
|
+
isLoadedInPlayground={row.isLoaded}
|
|
293
|
+
onTryIt={() => onTryEndpoint(row.endpoint)}
|
|
294
|
+
schemaId={row.schemaId}
|
|
295
|
+
/>
|
|
296
|
+
))}
|
|
132
297
|
</div>
|
|
133
298
|
)}
|
|
134
299
|
</div>
|
|
135
300
|
</div>
|
|
136
301
|
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Sections body (all schemas concatenated) ────────────────────────────────
|
|
305
|
+
|
|
306
|
+
function SectionsBody({
|
|
307
|
+
scrollRef,
|
|
308
|
+
schemasData,
|
|
309
|
+
selectedVersion,
|
|
310
|
+
loadedEndpoint,
|
|
311
|
+
onTryEndpoint,
|
|
312
|
+
}: SectionsProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
|
|
313
|
+
const sections = useMemo<SchemaSectionVM[]>(
|
|
314
|
+
() => schemasData.map((e) => buildSchemaSectionVM(e, selectedVersion, loadedEndpoint)),
|
|
315
|
+
[schemasData, selectedVersion, loadedEndpoint],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return (
|
|
319
|
+
<div ref={scrollRef}>
|
|
320
|
+
<div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12 space-y-16">
|
|
321
|
+
{sections.map((section) => (
|
|
322
|
+
<SchemaSectionView key={section.schemaId} section={section} onTryEndpoint={onTryEndpoint} />
|
|
323
|
+
))}
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const SchemaSectionView = React.memo(function SchemaSectionView({
|
|
330
|
+
section,
|
|
331
|
+
onTryEndpoint,
|
|
332
|
+
}: {
|
|
333
|
+
section: SchemaSectionVM;
|
|
334
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
335
|
+
}) {
|
|
336
|
+
const canCopy = section.rawSchema !== null && section.allEndpoints.length > 0;
|
|
337
|
+
return (
|
|
338
|
+
<section data-schema-anchor={section.schemaId} className="scroll-mt-20">
|
|
339
|
+
<header className="mb-8 pb-4 border-b">
|
|
340
|
+
<div className="flex items-start justify-between gap-4">
|
|
341
|
+
<div className="flex items-baseline gap-3 min-w-0">
|
|
342
|
+
<h2 className="text-2xl font-semibold tracking-tight">{section.title}</h2>
|
|
343
|
+
{section.version && (
|
|
344
|
+
<span className="font-mono text-xs text-muted-foreground/70">
|
|
345
|
+
v{section.version}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
{canCopy && (
|
|
350
|
+
<SchemaCopyMenu
|
|
351
|
+
schema={section.rawSchema}
|
|
352
|
+
endpoints={section.allEndpoints}
|
|
353
|
+
baseUrl={section.baseUrl}
|
|
354
|
+
/>
|
|
355
|
+
)}
|
|
356
|
+
</div>
|
|
357
|
+
{section.description && (
|
|
358
|
+
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
|
|
359
|
+
{section.description}
|
|
360
|
+
</p>
|
|
361
|
+
)}
|
|
362
|
+
</header>
|
|
363
|
+
<SchemaSectionStateView section={section} onTryEndpoint={onTryEndpoint} />
|
|
364
|
+
</section>
|
|
365
|
+
);
|
|
137
366
|
});
|
|
367
|
+
|
|
368
|
+
function SchemaSectionStateView({
|
|
369
|
+
section,
|
|
370
|
+
onTryEndpoint,
|
|
371
|
+
}: {
|
|
372
|
+
section: SchemaSectionVM;
|
|
373
|
+
onTryEndpoint: (ep: ApiEndpoint) => void;
|
|
374
|
+
}) {
|
|
375
|
+
switch (section.state.kind) {
|
|
376
|
+
case 'loading':
|
|
377
|
+
return (
|
|
378
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
379
|
+
Loading {section.title}…
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
case 'error':
|
|
383
|
+
return (
|
|
384
|
+
<div className="py-8 text-center text-sm text-destructive">
|
|
385
|
+
Failed to load {section.title}: {section.state.message}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
case 'empty':
|
|
389
|
+
return (
|
|
390
|
+
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
391
|
+
No endpoints in this schema.
|
|
392
|
+
</div>
|
|
393
|
+
);
|
|
394
|
+
case 'ready':
|
|
395
|
+
return (
|
|
396
|
+
<div className="divide-y divide-border/60">
|
|
397
|
+
{section.state.rows.map((row) => (
|
|
398
|
+
<EndpointDoc
|
|
399
|
+
key={row.key}
|
|
400
|
+
endpoint={row.endpoint}
|
|
401
|
+
isLoadedInPlayground={row.isLoaded}
|
|
402
|
+
onTryIt={() => onTryEndpoint(row.endpoint)}
|
|
403
|
+
schemaId={row.schemaId}
|
|
404
|
+
/>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
@@ -18,10 +18,14 @@ interface EndpointDocProps {
|
|
|
18
18
|
/** Is this endpoint currently loaded in the sticky playground? */
|
|
19
19
|
isLoadedInPlayground: boolean;
|
|
20
20
|
onTryIt: () => void;
|
|
21
|
+
/** Scoping prefix for the anchor, so endpoints from different schemas
|
|
22
|
+
* don't collide on a single page. Falls back to ``endpoint.schemaId``. */
|
|
23
|
+
schemaId?: string | null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: EndpointDocProps) {
|
|
24
|
-
const
|
|
26
|
+
export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt, schemaId }: EndpointDocProps) {
|
|
27
|
+
const scopedSchemaId = schemaId ?? endpoint.schemaId ?? null;
|
|
28
|
+
const anchor = endpointAnchor(endpoint, scopedSchemaId);
|
|
25
29
|
const pathParams = endpoint.parameters?.filter((p) => endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
26
30
|
const queryParams = endpoint.parameters?.filter((p) => !endpoint.path.includes(`{${p.name}}`)) ?? [];
|
|
27
31
|
|
|
@@ -43,6 +47,7 @@ export function EndpointDoc({ endpoint, isLoadedInPlayground, onTryIt }: Endpoin
|
|
|
43
47
|
<section
|
|
44
48
|
id={anchor}
|
|
45
49
|
data-endpoint-anchor={anchor}
|
|
50
|
+
data-schema-id={scopedSchemaId ?? ''}
|
|
46
51
|
className="scroll-mt-24 py-10 first:pt-0"
|
|
47
52
|
>
|
|
48
53
|
<header className="space-y-4">
|