@djangocfg/ui-tools 2.1.287 → 2.1.290

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.
Files changed (105) hide show
  1. package/README.md +14 -3
  2. package/dist/DocsLayout-IKH7BLSU.cjs +3464 -0
  3. package/dist/DocsLayout-IKH7BLSU.cjs.map +1 -0
  4. package/dist/DocsLayout-JPXFUKAR.mjs +3457 -0
  5. package/dist/DocsLayout-JPXFUKAR.mjs.map +1 -0
  6. package/dist/{PrettyCode.client-5GABIN2I.cjs → PrettyCode.client-RPDIE5CH.cjs} +104 -3
  7. package/dist/PrettyCode.client-RPDIE5CH.cjs.map +1 -0
  8. package/dist/{PrettyCode.client-IZTXXYHG.mjs → PrettyCode.client-SPMTQEG4.mjs} +106 -5
  9. package/dist/PrettyCode.client-SPMTQEG4.mjs.map +1 -0
  10. package/dist/{chunk-IULI4XII.cjs → chunk-5Q4UMSWB.cjs} +355 -9
  11. package/dist/chunk-5Q4UMSWB.cjs.map +1 -0
  12. package/dist/{chunk-VZGQC3NG.mjs → chunk-EFWOJPA6.mjs} +349 -9
  13. package/dist/chunk-EFWOJPA6.mjs.map +1 -0
  14. package/dist/index.cjs +10 -10
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +34 -0
  17. package/dist/index.d.ts +34 -0
  18. package/dist/index.mjs +5 -5
  19. package/dist/index.mjs.map +1 -1
  20. package/package.json +21 -14
  21. package/src/components/markdown/MarkdownMessage.tsx +46 -0
  22. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +93 -157
  23. package/src/tools/OpenapiViewer/README.md +114 -6
  24. package/src/tools/OpenapiViewer/components/DocsLayout/ApiIntroSection.tsx +20 -6
  25. package/src/tools/OpenapiViewer/components/DocsLayout/DocsView.tsx +331 -53
  26. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/LanguageTabs.tsx +36 -0
  27. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/index.tsx +56 -0
  28. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/CodeSamples/useCodeSnippet.ts +77 -0
  29. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +146 -0
  30. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MethodBadge.tsx +6 -0
  31. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/PathDisplay.tsx +26 -0
  32. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/index.tsx +87 -0
  33. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamGroup.tsx +30 -0
  34. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/ParamRow.tsx +36 -0
  35. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Parameters/index.tsx +22 -0
  36. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/RequestBody/index.tsx +33 -0
  37. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseBody.tsx +76 -0
  38. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/ResponseRow.tsx +80 -0
  39. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/StatusTag.tsx +32 -0
  40. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Responses/index.tsx +21 -0
  41. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/FieldRow.tsx +106 -0
  42. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/buildTree.ts +127 -0
  43. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/index.tsx +31 -0
  44. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/SchemaFields/types.ts +28 -0
  45. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/SectionHeader.tsx +87 -0
  46. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/defaults.ts +27 -0
  47. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/Section/index.tsx +45 -0
  48. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/context.tsx +56 -0
  49. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/hooks/useSectionHash.ts +63 -0
  50. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/index.tsx +96 -0
  51. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/index.ts +133 -0
  52. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/store/selectors.ts +40 -0
  53. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc/types.ts +17 -0
  54. package/src/tools/OpenapiViewer/components/DocsLayout/SchemaCopyMenu.tsx +40 -11
  55. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/BrandHeader.tsx +48 -0
  56. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/CategoryBlock.tsx +33 -0
  57. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/EndpointRow.tsx +73 -0
  58. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/MethodChips.tsx +43 -0
  59. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SchemaSection.tsx +27 -0
  60. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SearchInput.tsx +45 -0
  61. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/SidebarBody.tsx +50 -0
  62. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/Toolbar.tsx +64 -0
  63. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/buildVM.ts +126 -0
  64. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/index.tsx +112 -0
  65. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/types.ts +42 -0
  66. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar/useDebouncedValue.ts +14 -0
  67. package/src/tools/OpenapiViewer/components/DocsLayout/SlideInPlayground.tsx +10 -7
  68. package/src/tools/OpenapiViewer/components/DocsLayout/TryItSheet.tsx +9 -6
  69. package/src/tools/OpenapiViewer/components/DocsLayout/anchor.ts +19 -2
  70. package/src/tools/OpenapiViewer/components/DocsLayout/grouping.ts +38 -21
  71. package/src/tools/OpenapiViewer/components/DocsLayout/index.tsx +168 -50
  72. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PrettyView.tsx +55 -0
  73. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/PreviewView.tsx +115 -0
  74. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/RawView.tsx +24 -0
  75. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/StatusBar.tsx +63 -0
  76. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/ViewTabs.tsx +45 -0
  77. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/detectContent.ts +97 -0
  78. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/index.tsx +93 -0
  79. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/types.ts +26 -0
  80. package/src/tools/OpenapiViewer/components/shared/ResponsePanel/useResponseView.ts +62 -0
  81. package/src/tools/OpenapiViewer/hooks/index.ts +3 -1
  82. package/src/tools/OpenapiViewer/hooks/useDocsUrlSync.ts +119 -0
  83. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +164 -74
  84. package/src/tools/OpenapiViewer/types.ts +46 -1
  85. package/src/tools/OpenapiViewer/utils/codeSamples.ts +287 -0
  86. package/src/tools/OpenapiViewer/utils/index.ts +3 -0
  87. package/src/tools/OpenapiViewer/utils/operationToHar.ts +119 -0
  88. package/src/tools/OpenapiViewer/utils/sampler.ts +72 -0
  89. package/src/tools/OpenapiViewer/utils/scrollParent.ts +68 -0
  90. package/src/tools/PrettyCode/PrettyCode.client.tsx +88 -1
  91. package/src/tools/PrettyCode/PrettyCode.story.tsx +114 -361
  92. package/src/tools/PrettyCode/index.tsx +13 -0
  93. package/src/tools/PrettyCode/lazy.tsx +5 -0
  94. package/src/tools/PrettyCode/registerPrismLanguages.ts +111 -0
  95. package/dist/DocsLayout-BCVU6TTX.cjs +0 -2027
  96. package/dist/DocsLayout-BCVU6TTX.cjs.map +0 -1
  97. package/dist/DocsLayout-ERETJLLV.mjs +0 -2020
  98. package/dist/DocsLayout-ERETJLLV.mjs.map +0 -1
  99. package/dist/PrettyCode.client-5GABIN2I.cjs.map +0 -1
  100. package/dist/PrettyCode.client-IZTXXYHG.mjs.map +0 -1
  101. package/dist/chunk-IULI4XII.cjs.map +0 -1
  102. package/dist/chunk-VZGQC3NG.mjs.map +0 -1
  103. package/src/tools/OpenapiViewer/components/DocsLayout/EndpointDoc.tsx +0 -268
  104. package/src/tools/OpenapiViewer/components/DocsLayout/Sidebar.tsx +0 -211
  105. package/src/tools/OpenapiViewer/components/shared/ResponsePanel.tsx +0 -127
@@ -13,7 +13,21 @@ interface ApiIntroSectionProps {
13
13
  resolvedBaseUrl?: string;
14
14
  }
15
15
 
16
+ interface BaseUrlRow {
17
+ url: string;
18
+ description?: string;
19
+ }
20
+
16
21
  export function ApiIntroSection({ info, schema, endpoints, resolvedBaseUrl }: ApiIntroSectionProps) {
22
+ // Prefer the *resolved* base URL whenever we have one — that's the
23
+ // URL actual requests target, not the raw ``servers[0].url`` from
24
+ // the spec (which can be a bare path like ``/api/v3``). Fall back
25
+ // to the spec's ``servers`` list so specs that document multiple
26
+ // servers keep showing all of them.
27
+ const baseUrlRows: BaseUrlRow[] = resolvedBaseUrl
28
+ ? [{ url: resolvedBaseUrl, description: info.servers?.[0]?.description }]
29
+ : (info.servers ?? []).map((s) => ({ url: s.url, description: s.description }));
30
+
17
31
  return (
18
32
  <section className="pb-10 mb-10 border-b">
19
33
  <div className="flex items-start justify-between gap-4 flex-wrap">
@@ -38,20 +52,20 @@ export function ApiIntroSection({ info, schema, endpoints, resolvedBaseUrl }: Ap
38
52
  </div>
39
53
  )}
40
54
 
41
- {info.servers && info.servers.length > 0 && (
55
+ {baseUrlRows.length > 0 && (
42
56
  <div className="mt-6 space-y-2">
43
57
  <h4 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
44
58
  Base URL
45
59
  </h4>
46
60
  <div className="space-y-1.5">
47
- {info.servers.map((s, i) => (
48
- <div key={`${s.url}-${i}`} className="flex items-baseline gap-2 flex-wrap">
61
+ {baseUrlRows.map((row, i) => (
62
+ <div key={`${row.url}-${i}`} className="flex items-baseline gap-2 flex-wrap">
49
63
  <code className="font-mono text-xs px-2 py-1 rounded bg-muted border">
50
- {s.url}
64
+ {row.url}
51
65
  </code>
52
- {s.description && (
66
+ {row.description && (
53
67
  <span className="text-xs text-muted-foreground">
54
- {s.description}
68
+ {row.description}
55
69
  </span>
56
70
  )}
57
71
  </div>
@@ -2,17 +2,29 @@
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 { endpointAnchor } from './anchor';
17
+ import { useSectionHashRouter } from './EndpointDoc/hooks/useSectionHash';
18
+ import { SchemaCopyMenu } from './SchemaCopyMenu';
10
19
 
11
20
  export interface DocsViewHandle {
12
21
  scrollToAnchor: (anchor: string) => void;
13
22
  }
14
23
 
15
- interface DocsViewProps {
24
+ // ─── Props ───────────────────────────────────────────────────────────────────
25
+
26
+ interface SelectorProps {
27
+ grouping?: 'selector';
16
28
  info: OpenApiInfo | null;
17
29
  rawSchema: OpenApiSchema | null;
18
30
  resolvedBaseUrl?: string;
@@ -20,48 +32,174 @@ interface DocsViewProps {
20
32
  selectedVersion: string;
21
33
  loadedEndpoint: ApiEndpoint | null;
22
34
  onTryEndpoint: (ep: ApiEndpoint) => void;
23
- onActiveChange: (anchor: string | null) => void;
35
+ onActiveChange: (anchor: string | null, schemaId: string | null) => void;
36
+ }
37
+
38
+ interface SectionsProps {
39
+ grouping: 'sections';
40
+ /** Per-schema data (info + endpoints). Rendered in order. */
41
+ schemasData: LoadedSchemaEntry[];
42
+ selectedVersion: string;
43
+ loadedEndpoint: ApiEndpoint | null;
44
+ onTryEndpoint: (ep: ApiEndpoint) => void;
45
+ onActiveChange: (anchor: string | null, schemaId: string | null) => void;
46
+ }
47
+
48
+ type DocsViewProps = SelectorProps | SectionsProps;
49
+
50
+ // ─── View-model types ────────────────────────────────────────────────────────
51
+
52
+ interface EndpointRow {
53
+ key: string;
54
+ endpoint: ApiEndpoint;
55
+ isLoaded: boolean;
56
+ schemaId: string | null;
57
+ }
58
+
59
+ type SectionState =
60
+ | { kind: 'ready'; rows: EndpointRow[] }
61
+ | { kind: 'loading' }
62
+ | { kind: 'error'; message: string }
63
+ | { kind: 'empty' };
64
+
65
+ interface SchemaSectionVM {
66
+ schemaId: string;
67
+ title: string;
68
+ version: string | null;
69
+ description: string | null;
70
+ state: SectionState;
71
+ /** Copy-for-AI payload. ``null`` when the section is still loading
72
+ * or failed — the dropdown stays disabled. */
73
+ rawSchema: OpenApiSchema | null;
74
+ baseUrl: string | undefined;
75
+ allEndpoints: ApiEndpoint[];
76
+ }
77
+
78
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
79
+
80
+ /** Pixel offset from the top of the scroll container where the viewer
81
+ * should "park" sections. Reads ``--navbar-height`` for back-compat
82
+ * with pages that already set it; defaults to ``0`` for embedded /
83
+ * no-navbar setups (the common case when hosted in a shell). */
84
+ const readNavbarOffset = () => {
85
+ if (typeof document === 'undefined') return 0;
86
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--navbar-height');
87
+ const parsed = parseInt(raw || '', 10);
88
+ return Number.isFinite(parsed) ? parsed : 0;
89
+ };
90
+
91
+ const isSameEndpoint = (a: ApiEndpoint | null, b: ApiEndpoint) =>
92
+ a !== null && a.method === b.method && a.path === b.path;
93
+
94
+ function buildEndpointRow(
95
+ ep: ApiEndpoint,
96
+ loadedEndpoint: ApiEndpoint | null,
97
+ schemaId: string | null,
98
+ ): EndpointRow {
99
+ const keySchema = schemaId ? `${schemaId}-` : '';
100
+ return {
101
+ key: `${keySchema}${ep.method}-${ep.path}`,
102
+ endpoint: ep,
103
+ isLoaded: isSameEndpoint(loadedEndpoint, ep),
104
+ schemaId,
105
+ };
106
+ }
107
+
108
+ function buildSchemaSectionVM(
109
+ entry: LoadedSchemaEntry,
110
+ selectedVersion: string,
111
+ loadedEndpoint: ApiEndpoint | null,
112
+ ): SchemaSectionVM {
113
+ const title = entry.info?.title ?? entry.source.name;
114
+ const version = entry.info?.version ?? null;
115
+ const description = entry.info?.description ?? null;
116
+
117
+ let state: SectionState;
118
+ if (entry.loading) {
119
+ state = { kind: 'loading' };
120
+ } else if (entry.error) {
121
+ state = { kind: 'error', message: entry.error };
122
+ } else {
123
+ const visible = deduplicateEndpoints(entry.endpoints, selectedVersion);
124
+ state = visible.length === 0
125
+ ? { kind: 'empty' }
126
+ : {
127
+ kind: 'ready',
128
+ rows: visible.map((ep) => buildEndpointRow(ep, loadedEndpoint, entry.source.id)),
129
+ };
130
+ }
131
+
132
+ return {
133
+ schemaId: entry.source.id,
134
+ title,
135
+ version,
136
+ description,
137
+ state,
138
+ rawSchema: entry.rawSchema,
139
+ baseUrl: entry.resolvedBaseUrl,
140
+ allEndpoints: entry.endpoints,
141
+ };
24
142
  }
25
143
 
144
+ // ─── Component ───────────────────────────────────────────────────────────────
145
+
26
146
  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
- },
147
+ props,
37
148
  ref,
38
149
  ) {
39
150
  const scrollRef = useRef<HTMLDivElement | null>(null);
151
+ const scrollTargetRef = useRef<ScrollTarget | null>(null);
152
+ const { onActiveChange } = props;
40
153
 
41
- const visibleEndpoints = useMemo(
42
- () => deduplicateEndpoints(endpoints, selectedVersion),
43
- [endpoints, selectedVersion],
44
- );
154
+ // ``#section=<endpointId>.<sectionId>`` shareable deep-links —
155
+ // opens the referenced section in the store and scrolls it in.
156
+ // Idempotent, attaches a single hashchange listener.
157
+ useSectionHashRouter();
45
158
 
46
- // Scroll a given section into view. Imperative handle so the
47
- // sidebar (not a descendant) can trigger this without props drilling.
48
- const scrollToAnchor = useCallback((anchor: string) => {
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' });
159
+ // Resolve the real scroll container once the ref is attached. In
160
+ // standalone pages that's ``window``; inside an ``overflow-auto``
161
+ // shell (dev playground, modal) it's the wrapping DIV.
162
+ const ensureScrollTarget = useCallback((): ScrollTarget | null => {
163
+ if (scrollTargetRef.current) return scrollTargetRef.current;
164
+ if (!scrollRef.current) return null;
165
+ scrollTargetRef.current = getScrollParent(scrollRef.current);
166
+ return scrollTargetRef.current;
54
167
  }, []);
55
168
 
169
+ // Scroll a given section into view. Works against whichever ancestor
170
+ // actually scrolls — window for standalone, the overflow-auto parent
171
+ // for embedded layouts — so callers don't need to know the difference.
172
+ const scrollToAnchor = useCallback(
173
+ (anchor: string) => {
174
+ const root = scrollRef.current;
175
+ if (!root) return;
176
+ const el = root.querySelector<HTMLElement>(`[data-endpoint-anchor="${anchor}"]`);
177
+ if (!el) return;
178
+ const target = ensureScrollTarget();
179
+ if (!target) return;
180
+ const navbar = readNavbarOffset();
181
+ const top =
182
+ el.getBoundingClientRect().top -
183
+ getTargetTop(target) +
184
+ getScrollTop(target) -
185
+ navbar -
186
+ 8;
187
+ scrollTargetTo(target, top);
188
+ },
189
+ [ensureScrollTarget],
190
+ );
191
+
56
192
  React.useImperativeHandle(ref, () => ({ scrollToAnchor }), [scrollToAnchor]);
57
193
 
58
194
  // Scrollspy: pick the topmost endpoint section whose top is near the
59
- // upper third of the viewport. Runs on every scroll event (rAF-throttled)
60
- // an IntersectionObserver has flaky behaviour for "which one is active"
61
- // when several sections overlap the root margin band at once.
195
+ // upper quarter of the viewport. Listens on the real scroll container
196
+ // (see ``ensureScrollTarget``) because ``scroll`` events on a nested
197
+ // overflow:auto element do NOT bubble up to window.
62
198
  useEffect(() => {
63
199
  const root = scrollRef.current;
64
200
  if (!root) return;
201
+ const target = ensureScrollTarget();
202
+ if (!target) return;
65
203
 
66
204
  let rafId = 0;
67
205
  let lastActive: string | null = null;
@@ -70,20 +208,22 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
70
208
  rafId = 0;
71
209
  const sections = root.querySelectorAll<HTMLElement>('[data-endpoint-anchor]');
72
210
  if (sections.length === 0) return;
73
- const rootTop = root.getBoundingClientRect().top;
74
- const threshold = rootTop + root.clientHeight * 0.25;
75
- let active: string | null = null;
211
+ const navbar = readNavbarOffset();
212
+ const viewportTop = getTargetTop(target);
213
+ const threshold = viewportTop + navbar + getViewportHeight(target) * 0.25;
214
+ let active: HTMLElement | null = null;
76
215
  for (const s of Array.from(sections)) {
77
216
  const top = s.getBoundingClientRect().top;
78
217
  if (top <= threshold) {
79
- active = s.dataset.endpointAnchor ?? null;
218
+ active = s;
80
219
  } else {
81
220
  break;
82
221
  }
83
222
  }
84
- if (active !== lastActive) {
85
- lastActive = active;
86
- onActiveChange(active);
223
+ const anchor = active?.dataset.endpointAnchor ?? null;
224
+ if (anchor !== lastActive) {
225
+ lastActive = anchor;
226
+ onActiveChange(anchor, active?.dataset.schemaId || null);
87
227
  }
88
228
  };
89
229
 
@@ -93,15 +233,49 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
93
233
  };
94
234
 
95
235
  compute();
96
- root.addEventListener('scroll', onScroll, { passive: true });
236
+ target.addEventListener('scroll', onScroll, { passive: true });
237
+ // Resize always bubbles to window — listen there regardless of target.
238
+ window.addEventListener('resize', onScroll, { passive: true });
97
239
  return () => {
98
- root.removeEventListener('scroll', onScroll);
240
+ target.removeEventListener('scroll', onScroll);
241
+ window.removeEventListener('resize', onScroll);
99
242
  if (rafId) cancelAnimationFrame(rafId);
100
243
  };
101
- }, [visibleEndpoints, onActiveChange]);
244
+ }, [onActiveChange, ensureScrollTarget, props]);
245
+
246
+ if (props.grouping === 'sections') {
247
+ return <SectionsBody scrollRef={scrollRef} {...props} />;
248
+ }
249
+
250
+ return <SelectorBody scrollRef={scrollRef} {...props} />;
251
+ });
252
+
253
+ // ─── Selector body (single active schema) ────────────────────────────────────
254
+
255
+ function SelectorBody({
256
+ scrollRef,
257
+ info,
258
+ rawSchema,
259
+ resolvedBaseUrl,
260
+ endpoints,
261
+ selectedVersion,
262
+ loadedEndpoint,
263
+ onTryEndpoint,
264
+ }: SelectorProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
265
+ const visibleEndpoints = useMemo(
266
+ () => deduplicateEndpoints(endpoints, selectedVersion),
267
+ [endpoints, selectedVersion],
268
+ );
269
+
270
+ const rows = useMemo<EndpointRow[]>(
271
+ () => visibleEndpoints.map((ep) => buildEndpointRow(ep, loadedEndpoint, ep.schemaId ?? null)),
272
+ [visibleEndpoints, loadedEndpoint],
273
+ );
274
+
275
+ const isEmpty = rows.length === 0;
102
276
 
103
277
  return (
104
- <div ref={scrollRef} className="flex-1 overflow-y-auto min-h-0">
278
+ <div ref={scrollRef}>
105
279
  <div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12">
106
280
  {info && (
107
281
  <ApiIntroSection
@@ -111,27 +285,131 @@ export const DocsView = React.forwardRef<DocsViewHandle, DocsViewProps>(function
111
285
  resolvedBaseUrl={resolvedBaseUrl}
112
286
  />
113
287
  )}
114
- {visibleEndpoints.length === 0 ? (
288
+ {isEmpty ? (
115
289
  <div className="py-16 text-center text-sm text-muted-foreground">
116
290
  No endpoints to display.
117
291
  </div>
118
292
  ) : (
119
293
  <div className="divide-y divide-border/60">
120
- {visibleEndpoints.map((ep) => {
121
- const isLoaded =
122
- loadedEndpoint?.method === ep.method && loadedEndpoint?.path === ep.path;
123
- return (
124
- <EndpointDoc
125
- key={`${ep.method}-${ep.path}`}
126
- endpoint={ep}
127
- isLoadedInPlayground={isLoaded}
128
- onTryIt={() => onTryEndpoint(ep)}
129
- />
130
- );
131
- })}
294
+ {rows.map((row) => (
295
+ <EndpointDoc
296
+ key={row.key}
297
+ endpoint={row.endpoint}
298
+ isLoadedInPlayground={row.isLoaded}
299
+ onTryIt={() => onTryEndpoint(row.endpoint)}
300
+ schemaId={row.schemaId}
301
+ />
302
+ ))}
132
303
  </div>
133
304
  )}
134
305
  </div>
135
306
  </div>
136
307
  );
308
+ }
309
+
310
+ // ─── Sections body (all schemas concatenated) ────────────────────────────────
311
+
312
+ function SectionsBody({
313
+ scrollRef,
314
+ schemasData,
315
+ selectedVersion,
316
+ loadedEndpoint,
317
+ onTryEndpoint,
318
+ }: SectionsProps & { scrollRef: React.RefObject<HTMLDivElement | null> }) {
319
+ const sections = useMemo<SchemaSectionVM[]>(
320
+ () => schemasData.map((e) => buildSchemaSectionVM(e, selectedVersion, loadedEndpoint)),
321
+ [schemasData, selectedVersion, loadedEndpoint],
322
+ );
323
+
324
+ return (
325
+ <div ref={scrollRef}>
326
+ <div className="mx-auto w-full max-w-[860px] px-6 md:px-10 lg:px-14 py-12 space-y-16">
327
+ {sections.map((section) => (
328
+ <SchemaSectionView key={section.schemaId} section={section} onTryEndpoint={onTryEndpoint} />
329
+ ))}
330
+ </div>
331
+ </div>
332
+ );
333
+ }
334
+
335
+ const SchemaSectionView = React.memo(function SchemaSectionView({
336
+ section,
337
+ onTryEndpoint,
338
+ }: {
339
+ section: SchemaSectionVM;
340
+ onTryEndpoint: (ep: ApiEndpoint) => void;
341
+ }) {
342
+ const canCopy = section.rawSchema !== null && section.allEndpoints.length > 0;
343
+ return (
344
+ <section data-schema-anchor={section.schemaId} className="scroll-mt-20">
345
+ <header className="mb-8 pb-4 border-b">
346
+ <div className="flex items-start justify-between gap-4">
347
+ <div className="flex items-baseline gap-3 min-w-0">
348
+ <h2 className="text-2xl font-semibold tracking-tight">{section.title}</h2>
349
+ {section.version && (
350
+ <span className="font-mono text-xs text-muted-foreground/70">
351
+ v{section.version}
352
+ </span>
353
+ )}
354
+ </div>
355
+ {canCopy && (
356
+ <SchemaCopyMenu
357
+ schema={section.rawSchema}
358
+ endpoints={section.allEndpoints}
359
+ baseUrl={section.baseUrl}
360
+ />
361
+ )}
362
+ </div>
363
+ {section.description && (
364
+ <p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap">
365
+ {section.description}
366
+ </p>
367
+ )}
368
+ </header>
369
+ <SchemaSectionStateView section={section} onTryEndpoint={onTryEndpoint} />
370
+ </section>
371
+ );
137
372
  });
373
+
374
+ function SchemaSectionStateView({
375
+ section,
376
+ onTryEndpoint,
377
+ }: {
378
+ section: SchemaSectionVM;
379
+ onTryEndpoint: (ep: ApiEndpoint) => void;
380
+ }) {
381
+ switch (section.state.kind) {
382
+ case 'loading':
383
+ return (
384
+ <div className="py-8 text-center text-sm text-muted-foreground">
385
+ Loading {section.title}…
386
+ </div>
387
+ );
388
+ case 'error':
389
+ return (
390
+ <div className="py-8 text-center text-sm text-destructive">
391
+ Failed to load {section.title}: {section.state.message}
392
+ </div>
393
+ );
394
+ case 'empty':
395
+ return (
396
+ <div className="py-8 text-center text-sm text-muted-foreground">
397
+ No endpoints in this schema.
398
+ </div>
399
+ );
400
+ case 'ready':
401
+ return (
402
+ <div className="divide-y divide-border/60">
403
+ {section.state.rows.map((row) => (
404
+ <EndpointDoc
405
+ key={row.key}
406
+ endpoint={row.endpoint}
407
+ isLoadedInPlayground={row.isLoaded}
408
+ onTryIt={() => onTryEndpoint(row.endpoint)}
409
+ schemaId={row.schemaId}
410
+ />
411
+ ))}
412
+ </div>
413
+ );
414
+ }
415
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import { CODE_SAMPLE_TARGETS, type CodeSampleTargetId } from '../../../../utils/codeSamples';
6
+
7
+ interface LanguageTabsProps {
8
+ activeId: CodeSampleTargetId;
9
+ onChange: (id: CodeSampleTargetId) => void;
10
+ }
11
+
12
+ /** Horizontal tab strip for switching between cURL/JS/Python/… The
13
+ * strip scrolls horizontally on narrow viewports rather than wrapping
14
+ * to a second line so the adjacent code block keeps its vertical
15
+ * rhythm. */
16
+ export function LanguageTabs({ activeId, onChange }: LanguageTabsProps) {
17
+ return (
18
+ <div className="flex items-center gap-1 overflow-x-auto -mx-1 px-1">
19
+ {CODE_SAMPLE_TARGETS.map((t) => (
20
+ <button
21
+ key={t.id}
22
+ type="button"
23
+ onClick={() => onChange(t.id)}
24
+ className={cn(
25
+ 'shrink-0 h-7 px-2.5 rounded text-xs font-medium transition-colors',
26
+ activeId === t.id
27
+ ? 'bg-muted text-foreground'
28
+ : 'text-muted-foreground/70 hover:text-foreground hover:bg-muted/50',
29
+ )}
30
+ >
31
+ {t.label}
32
+ </button>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import PrettyCode from '../../../../../PrettyCode';
4
+ import type { ApiEndpoint } from '../../../../types';
5
+ import { useEndpointDocContext } from '../context';
6
+ import { useEndpointDocStore } from '../store';
7
+ import { useActiveCodeTab } from '../store/selectors';
8
+ import { LanguageTabs } from './LanguageTabs';
9
+ import { useCodeSnippet } from './useCodeSnippet';
10
+
11
+ interface CodeSamplesProps {
12
+ endpoint: ApiEndpoint;
13
+ /** Optional body to include in generated snippets. When omitted we
14
+ * use ``endpoint.requestBody?.example`` if present, so the snippet
15
+ * shows a realistic payload out of the box. */
16
+ body?: string;
17
+ /** Parameter values to substitute into the URL. Missing path params
18
+ * fall back to ``{name}`` placeholders so the snippet still
19
+ * illustrates the shape. */
20
+ parameters?: Record<string, string>;
21
+ /** Extra headers to include in snippets (e.g. a picked API key). */
22
+ headers?: Record<string, string>;
23
+ /** Base URL override — falls back to ``endpoint.path`` which
24
+ * already has the resolved base URL prepended by the extractor. */
25
+ baseUrl?: string;
26
+ }
27
+
28
+ /** Code samples block: language tab bar + highlighted snippet. The
29
+ * outer Section wrapper (collapsible) lives one level up; this
30
+ * component is always "open" from its own perspective. */
31
+ export function CodeSamples({ endpoint, body, parameters, headers, baseUrl }: CodeSamplesProps) {
32
+ const { endpointId } = useEndpointDocContext();
33
+ const activeId = useActiveCodeTab(endpointId);
34
+ const setCodeTab = useEndpointDocStore((s) => s.setCodeTab);
35
+
36
+ const { snippet, prism } = useCodeSnippet({
37
+ endpoint,
38
+ body,
39
+ parameters,
40
+ headers,
41
+ baseUrl,
42
+ activeId,
43
+ });
44
+
45
+ return (
46
+ <div className="space-y-2.5">
47
+ <LanguageTabs activeId={activeId} onChange={(id) => setCodeTab(endpointId, id)} />
48
+ <PrettyCode
49
+ data={snippet}
50
+ language={prism as never}
51
+ isCompact
52
+ maxLines={20}
53
+ />
54
+ </div>
55
+ );
56
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useMemo } from 'react';
4
+
5
+ import type { ApiEndpoint } from '../../../../types';
6
+ import {
7
+ CODE_SAMPLE_TARGETS,
8
+ renderSnippet,
9
+ type CodeSampleTargetId,
10
+ } from '../../../../utils/codeSamples';
11
+ import { buildHarRequest } from '../../../../utils/operationToHar';
12
+ import { resolveAbsolute } from '../../../../utils/url';
13
+
14
+ interface UseCodeSnippetInput {
15
+ endpoint: ApiEndpoint;
16
+ body?: string;
17
+ parameters?: Record<string, string>;
18
+ headers?: Record<string, string>;
19
+ baseUrl?: string;
20
+ activeId: CodeSampleTargetId;
21
+ }
22
+
23
+ interface UseCodeSnippetResult {
24
+ /** Fully-rendered snippet string. Always present — falls back to an
25
+ * "unavailable" message if the generator returns null, so the
26
+ * consumer can always mount ``PrettyCode``. */
27
+ snippet: string;
28
+ /** Prism language id matching ``activeId`` — passed to PrettyCode so
29
+ * it picks the right highlighter. */
30
+ prism: string;
31
+ }
32
+
33
+ /** Encapsulates HAR build + snippet render + memoisation for the Code
34
+ * Samples block. Kept as a hook (rather than inline ``useMemo`` blocks
35
+ * in the component) so unit tests can exercise the snippet pipeline
36
+ * independently of React rendering. */
37
+ export function useCodeSnippet({
38
+ endpoint,
39
+ body,
40
+ parameters,
41
+ headers,
42
+ baseUrl,
43
+ activeId,
44
+ }: UseCodeSnippetInput): UseCodeSnippetResult {
45
+ const effectiveBody = body ?? endpoint.requestBody?.example;
46
+
47
+ // Build the HAR once per input change — every tab rebuilds its
48
+ // snippet from this shared request shape.
49
+ //
50
+ // ``endpoint.path`` already carries the schema's ``servers[0].url``
51
+ // (joined upstream in ``useOpenApiSchema``). Usually that's a path
52
+ // like ``/api/v3/pet`` — good enough for same-origin fetch, wrong
53
+ // for curl/python/go which need a runnable absolute URL. We hand
54
+ // off to ``resolveAbsolute`` so the snippet is copy-pasteable from
55
+ // a terminal without the user having to edit the host in manually.
56
+ //
57
+ // Priority: explicit ``baseUrl`` prop > resolved origin > bare path.
58
+ const har = useMemo(() => {
59
+ const h = buildHarRequest({
60
+ endpoint,
61
+ body: effectiveBody,
62
+ parameters,
63
+ headers,
64
+ baseUrl,
65
+ });
66
+ return baseUrl ? h : { ...h, url: resolveAbsolute(h.url) };
67
+ }, [endpoint, effectiveBody, parameters, headers, baseUrl]);
68
+
69
+ return useMemo(() => {
70
+ const target = CODE_SAMPLE_TARGETS.find((t) => t.id === activeId)!;
71
+ const code = renderSnippet(har, activeId);
72
+ return {
73
+ snippet: code ?? `// Snippet for ${target.label} is unavailable for this request.`,
74
+ prism: target.prism,
75
+ };
76
+ }, [har, activeId]);
77
+ }