Skip to content

Commit 9c96fd8

Browse files
reakaleekclaude
andauthored
API rail: share the window height between request and response code (#4696)
* API rail: stop the Base URL and examples from scrolling Only the examples panel shrinks now, and the response fills the height left in the rail instead of a fixed guess. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * API rail: scroll as one column like the navigation Replace the shrinking examples chain with a single scrolling rail, and open the Copy page menu inward so the rail does not clip it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * API rail: share the window height between request and response code The rail no longer scrolls. The shorter sample shows in full and the longer one gets the rest, with at least five lines each. The examples column is 40px wider and the reference 40px narrower. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the API rail as tall as its content on short pages The rail had a fixed window height, so on short pages the first grid row grew to the window and pushed the page feedback down. The balance script now reads the rail's max-height instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: re-balance the API code heights when the language, tab or scenario changes A capped sample keeps its size when another one becomes active, so the resize observers never saw the change and a longer sample stayed clipped to the previous one's height. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: count a horizontal scrollbar in the API code heights scrollHeight leaves out a horizontal scrollbar while max-height counts it, so with classic scrollbars a short sample with a wide line lost its last line to a vertical scrollbar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: share one resize listener across API example panels (per review by @infra-vault-gh-plugin-prod) Every HTMX navigation added a window listener that kept the previous page's panel alive. One listener now balances the connected panels and stops the observers of the detached ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover API code re-balancing on window growth and language change (per review by @infra-vault-gh-plugin-prod) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: cover the horizontal scrollbar in API code heights (per review by @infra-vault-gh-plugin-prod) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Jan Calanog <reakaleek@users.noreply.github.com> Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent e81b113 commit 9c96fd8

8 files changed

Lines changed: 461 additions & 24 deletions

File tree

‎src/Elastic.Documentation.Site/Assets/api-docs.css‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,6 +1031,13 @@
10311031
overflow-wrap: anywhere;
10321032
word-break: break-word;
10331033
}
1034+
/* Registered as a length so api-examples-balance.ts reads the 16-line cap in pixels. */
1035+
@property --api-code-cap {
1036+
syntax: '<length>';
1037+
inherits: true;
1038+
initial-value: 0px;
1039+
}
1040+
10341041
.api-page-intro,
10351042
#elastic-api-v3,
10361043
#schema-definition,
@@ -2964,7 +2971,7 @@ button.example-response-tab:focus-visible {
29642971
}
29652972

29662973
.api-page-grid {
2967-
grid-template-columns: minmax(0, 1fr) minmax(0, 40%);
2974+
grid-template-columns: minmax(0, 1fr) minmax(0, 44%);
29682975
grid-template-rows: auto auto;
29692976
}
29702977

@@ -2985,8 +2992,9 @@ button.example-response-tab:focus-visible {
29852992
min-width: 0;
29862993
padding-top: 16px;
29872994
padding-bottom: 48px;
2988-
/* Stays in view while the sections scroll, however long the page is. The rail itself does not clip, so the
2989-
Copy page menu can open past its edge; the examples below it shrink to the window and scroll. */
2995+
/* Stays in view while the sections scroll and never scrolls itself. api-examples-balance.ts reads this
2996+
max-height to share the room left under the chips and captions between the request and the response
2997+
code. It does not clip, so the Copy page menu can open past its edge. */
29902998
position: sticky;
29912999
top: var(--offset-top, 0px);
29923000
align-self: start;
@@ -2995,9 +3003,14 @@ button.example-response-tab:focus-visible {
29953003
}
29963004

29973005
.api-page-rail > :not(.api-page-actions) {
3006+
flex: 0 0 auto;
3007+
}
3008+
3009+
/* Scrolls only in a window too short for the chips, headers and captions alone. */
3010+
.api-page-rail > #api-examples-panel {
29983011
flex: 0 1 auto;
29993012
min-height: 0;
3000-
overflow: hidden auto;
3013+
overflow-y: auto;
30013014
}
30023015

30033016
.api-page-rail > .api-page-actions {
@@ -3994,15 +4007,14 @@ html:has(.api-page-shell) {
39944007
overflow: auto;
39954008
}
39964009

3997-
/* Beside the article the rail is sticky and as tall as the window, so the response fills what is left under
3998-
the request instead of stopping at 12 lines. 26rem is the rail's padding, Copy page and the request
3999-
above the response. The 12-line cap stays as the minimum. */
4010+
/* Beside the article api-examples-balance.ts sets both heights from the room left in the rail. */
40004011
@media (min-width: 1024px) {
4012+
#api-examples-panel .api-code-carousel-card-body {
4013+
max-height: var(--api-request-code-max, var(--api-code-cap));
4014+
}
4015+
40014016
#api-examples-panel .example-block-body {
4002-
max-height: max(
4003-
var(--api-response-cap),
4004-
calc(100dvh - var(--offset-top, 0px) - 26rem)
4005-
);
4017+
max-height: var(--api-response-code-max, var(--api-response-cap));
40064018
}
40074019
}
40084020

‎src/Elastic.Documentation.Site/Assets/api-docs.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* for both OperationView and SchemaView pages.
55
*/
66
import { decorateApiCodeTokens } from './api-code-tokens'
7+
import { initExamplesBalance } from './api-examples-balance'
78
import { initApiExamples, syncDescriptions } from './api-examples-carousel'
89
import { fitParamSummaries } from './api-param-summary'
910
import { initUnionChips, revealVariantsAround } from './api-union-chips'
@@ -763,6 +764,7 @@ export function initApiDocs(): void {
763764
initApiPropertyAnchorCopy()
764765
initApiResponseStatusTabs()
765766
initApiExamples()
767+
initExamplesBalance()
766768
initUnionChips()
767769
initApiPageActions()
768770
initExamplesRailSteadying()
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { balanceCodeHeights, initExamplesBalance } from './api-examples-balance'
2+
3+
const floor = 112
4+
5+
describe('balanceCodeHeights', () => {
6+
it('shows both in full when they fit', () => {
7+
expect(
8+
balanceCodeHeights(500, { request: 200, response: 100 }, floor)
9+
).toEqual({ request: 200, response: 100 })
10+
})
11+
12+
it('gives a one-line response its full height and the rest to the request', () => {
13+
expect(
14+
balanceCodeHeights(300, { request: 360, response: 24 }, floor)
15+
).toEqual({ request: 276, response: 24 })
16+
})
17+
18+
it('gives a short request its full height and the rest to the response', () => {
19+
expect(
20+
balanceCodeHeights(400, { request: 120, response: 4644 }, floor)
21+
).toEqual({ request: 120, response: 280 })
22+
})
23+
24+
it('splits the room evenly between two long samples', () => {
25+
expect(
26+
balanceCodeHeights(400, { request: 360, response: 4644 }, floor)
27+
).toEqual({ request: 200, response: 200 })
28+
})
29+
30+
it('keeps five lines of each when the window is too short', () => {
31+
expect(
32+
balanceCodeHeights(-50, { request: 360, response: 4644 }, floor)
33+
).toEqual({ request: 112, response: 112 })
34+
})
35+
36+
it('never grows a sample past its own height to reach the floor', () => {
37+
expect(
38+
balanceCodeHeights(0, { request: 360, response: 42 }, floor)
39+
).toEqual({ request: 112, response: 42 })
40+
})
41+
})
42+
43+
interface Sample {
44+
lang: string
45+
scroll: number
46+
offset?: number
47+
}
48+
49+
function measure(
50+
element: Element | null,
51+
sizes: {
52+
height?: number
53+
scroll?: number
54+
offset?: number
55+
client?: number
56+
}
57+
) {
58+
if (!element) throw new Error('missing element')
59+
const height = sizes.height ?? 0
60+
element.getBoundingClientRect = () =>
61+
({ top: 0, bottom: height, height }) as DOMRect
62+
for (const [key, value] of [
63+
['scrollHeight', sizes.scroll],
64+
['offsetHeight', sizes.offset ?? sizes.scroll],
65+
['clientHeight', sizes.client ?? sizes.scroll],
66+
] as const)
67+
if (value !== undefined)
68+
Object.defineProperty(element, key, { configurable: true, value })
69+
}
70+
71+
/**
72+
* Around the code there are 202px: the rail's 100px outside the panel, and 102px in the panel outside the strip's
73+
* code and the response body. So the code gets the rail's max-height minus 302px.
74+
*/
75+
function rail(maxHeight: number, samples: Sample[], response: Sample) {
76+
const cards = samples
77+
.map(
78+
({ lang }, i) =>
79+
`<section class="api-code-carousel-card${i === 0 ? ' is-active' : ''}" data-lang="${lang}"><header></header><div class="api-code-carousel-card-body"></div></section>`
80+
)
81+
.join('')
82+
const host = document.createElement('div')
83+
host.innerHTML = `
84+
<div class="api-page-rail" style="max-height: ${maxHeight}px">
85+
<div id="api-examples-panel" style="--api-code-cap: 360px">
86+
<div data-api-examples>
87+
<div data-api-carousel><div data-carousel-strip>${cards}</div></div>
88+
<div class="example-block--response"><div class="example-block-body"></div></div>
89+
</div>
90+
</div>
91+
</div>`
92+
const root = host.firstElementChild as HTMLElement
93+
document.body.append(root)
94+
measure(root, { height: 600 })
95+
measure(root.querySelector('#api-examples-panel'), { height: 500 })
96+
measure(root.querySelector('[data-api-examples]'), { height: 500 })
97+
measure(root.querySelector('[data-carousel-strip]'), { height: 200 })
98+
root.querySelectorAll('.api-code-carousel-card-body').forEach((body, i) =>
99+
measure(body, samples[i])
100+
)
101+
measure(root.querySelector('.example-block-body'), {
102+
...response,
103+
height: 100,
104+
})
105+
return root
106+
}
107+
108+
function heights(root: HTMLElement) {
109+
const panel = root.querySelector<HTMLElement>('#api-examples-panel')!
110+
return {
111+
request: panel.style.getPropertyValue('--api-request-code-max'),
112+
response: panel.style.getPropertyValue('--api-response-code-max'),
113+
}
114+
}
115+
116+
const longResponse = { lang: 'JSON', scroll: 4644 }
117+
118+
describe('initExamplesBalance', () => {
119+
beforeEach(() => {
120+
vi.stubGlobal('matchMedia', () => ({ matches: true }))
121+
vi.stubGlobal('requestAnimationFrame', (run: FrameRequestCallback) => {
122+
run(0)
123+
return 1
124+
})
125+
vi.stubGlobal('cancelAnimationFrame', () => {})
126+
vi.stubGlobal(
127+
'ResizeObserver',
128+
class {
129+
observe() {}
130+
disconnect() {}
131+
}
132+
)
133+
})
134+
135+
afterEach(() => {
136+
document.body.replaceChildren()
137+
vi.unstubAllGlobals()
138+
})
139+
140+
it('gives the code the room a taller window adds', () => {
141+
const root = rail(602, [{ lang: 'Console', scroll: 400 }], longResponse)
142+
initExamplesBalance()
143+
const before = heights(root)
144+
145+
root.style.maxHeight = '1002px'
146+
window.dispatchEvent(new Event('resize'))
147+
148+
expect([before, heights(root)]).toEqual([
149+
{ request: '150px', response: '150px' },
150+
{ request: '350px', response: '350px' },
151+
])
152+
})
153+
154+
it('re-balances when a longer language becomes active', async () => {
155+
const root = rail(
156+
1302,
157+
[
158+
{ lang: 'Console', scroll: 100 },
159+
{ lang: 'Java', scroll: 300 },
160+
],
161+
{ lang: 'JSON', scroll: 50 }
162+
)
163+
initExamplesBalance()
164+
const before = heights(root)
165+
166+
const [consoleCard, javaCard] = root.querySelectorAll(
167+
'.api-code-carousel-card'
168+
)
169+
consoleCard.classList.remove('is-active')
170+
javaCard.classList.add('is-active')
171+
await new Promise((resolve) => setTimeout(resolve, 0))
172+
173+
expect([before, heights(root)]).toEqual([
174+
{ request: '100px', response: '50px' },
175+
{ request: '300px', response: '50px' },
176+
])
177+
})
178+
179+
it('counts a horizontal scrollbar in a short response', () => {
180+
const root = rail(1302, [{ lang: 'Console', scroll: 100 }], {
181+
lang: 'JSON',
182+
scroll: 40,
183+
offset: 48,
184+
})
185+
initExamplesBalance()
186+
187+
expect(heights(root)).toEqual({ request: '100px', response: '48px' })
188+
})
189+
190+
it('stops balancing a panel once HTMX has swapped it out', () => {
191+
const old = rail(602, [{ lang: 'Console', scroll: 400 }], longResponse)
192+
initExamplesBalance()
193+
old.remove()
194+
const current = rail(
195+
602,
196+
[{ lang: 'Console', scroll: 400 }],
197+
longResponse
198+
)
199+
initExamplesBalance()
200+
201+
old.style.maxHeight = '1002px'
202+
current.style.maxHeight = '1002px'
203+
window.dispatchEvent(new Event('resize'))
204+
205+
expect([heights(old), heights(current)]).toEqual([
206+
{ request: '150px', response: '150px' },
207+
{ request: '350px', response: '350px' },
208+
])
209+
})
210+
})

0 commit comments

Comments
 (0)