Skip to content

Commit fa01507

Browse files
reakaleekclaude
andauthored
Let readers resize the columns on API pages (#4818)
* Let readers resize the sidebar and examples columns on API pages Drag handles now sit on the edges of the sidebar and the examples rail. Arrow keys on a focused handle move the edge in 24px steps, and a double click resets the column. The widths persist in `localStorage` under `api-column-widths`. The reference column keeps at least 360px, the sidebar stops at 240px, and the handles show only on wide screens. Both the design-system layout and the legacy layout are covered. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Release the listeners of a replaced shell on API pages Boosted navigation swaps the whole `.api-page-shell`. Each swap left a `window` resize listener and a `ResizeObserver` behind, and both kept the detached page alive. `initApiColumnResize` now keeps one active shell and disposes the previous one first. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Fit saved column widths to the window on API pages Saved widths were restored with only the fixed limits, so a width saved on a wide monitor squeezed the reference column in a smaller window. The inline widths also stayed in force below the breakpoint where the handles are hidden, so a reader could not undo them. Saved widths now apply only where the handles show, shrink (rail first) until the reference keeps its 360px, and are re-fitted on resize. The stored values stay as they are for the next wide window. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Keep the sidebar handle off the sidebar's scrollbar The sidebar handle was centred on the column's right edge, so half of its 12px hit area covered the sidebar's scrollbar. Dragging the scrollbar resized the column instead of scrolling the list. The handle now starts at the edge, so its hit area sits in the gap. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Test the content-minimum cap and pointer drags for API columns The tests only moved columns by one key step well inside the available room, so a regression in the cap that keeps the reference at 360px, or in saving a drag, would still pass. New tests cover a key step and a drag that hit the cap, and a drag that saves its width only when it ends. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Let the design-system sidebar start a drag from its default width The design-system sidebar is 224px by default at wide windows, and the drag minimum was 240px. The first pointer move snapped the column up by 16px. The minimum for that layout now matches its stylesheet floor of 14rem. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Keep the examples rail at least 615px wide on API pages The code samples in the rail need room, so the drag limit for the rail rises from 280px to 615px in both layouts. A rail that starts below that width snaps to 615px on the first drag. The stylesheet defaults are unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Draw the sidebar handle's bar on the sidebar's border Moving the sidebar handle off the scrollbar also moved its line and bar 6px right of the sidebar's border. The handle's hit area still starts at the edge, but the line and the bar are now drawn back onto the edge, and they ignore the pointer so they cannot cover the scrollbar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Lower the examples rail drag minimum to 600px Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Make 600px the default width of the examples rail on API pages The drag minimum of the rail was above its stylesheet default (440px in the legacy layout, 320px in the design-system layout), so a double click reset the rail to a width the drag could not reach. Where the handles show, the default is now 600px. It tapers in windows too narrow to hold 600px beside a 360px reference column, so the page does not overflow. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Let readers narrow the examples rail back to its previous width The rail keeps its new 600px default, but the drag minimum returns to the rail's previous default: 440px in the legacy layout and 320px in the design-system layout. Readers who prefer more room for the reference can get the old layout back by dragging. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Lay API pages out as three full columns up to a 1920px window The reference and the rail were capped as one block and centred beside the sidebar, so a resized column only moved space around inside that block. Up to a 1920px window the reference now takes whatever the sidebar and the rail leave. Past 1920px the columns keep the widths they have at 1920px and sit centred. In the design-system layout the page takes the wide limit on API pages, and the header follows because it shares `--layout-max`. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * Keep API pages readable at laptop and very wide windows The rail's 600px default left the reference only 484px in a 1440px window. The default now grows with the window, from the rail's previous width at the breakpoint to 600px at 1700px. At 1920px the fluid reference ran prose to about 140 characters a line, so paragraphs, lists and callouts in the reference now keep the docs text width of 48rem. Parameter rows and code still use the whole column. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com> * Make the whole sidebar handle bar grabbable The sidebar handle's hit area started at the sidebar's edge, but its bar and line are drawn centred on that edge. A double click or a drag aimed at the left half of the bar landed on the sidebar instead. The hit area now starts 3px inside the sidebar and covers the whole bar, which still leaves most of the sidebar's scrollbar clear. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Colour the resize bar with a design-system role `api-reference.css` must use colour roles, and the idle bar used the hex literal `#b6c0d1`, which failed `ComponentCss_ReadsTokensOnly` in `Navigation.Tests`. The bar now uses `--color-border-control`, so it also follows the theme. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Make the column tests follow the widths the script applies The mocked column rectangles never changed, so the key-step test accepted a sidebar that grew right after the rail used up all the spare room. The mocks now read the applied `--layout-nav-width` and `--layout-rail-width`, as the real layout does, and the test expects the sidebar to stay put. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Widen only the API page shell, not the header The 1920px layout set `--layout-max` on the `body` of every design-system API page. The injected elastic.co masthead follows that token, so on the assembled site the masthead and docs bar widened on API pages and snapped back on every other page. The token now changes on the shell only, and the header keeps its contained width. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Release the resizer when navigation leaves the API pages When boosted navigation replaced an API page with a regular documentation page, `initApiColumnResize` returned early without cleaning up. The observer and the window resize listener kept reconciling a detached shell until another API page opened. The early return now disposes the active shell. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Measure the reference column to find the room left for resizing `slack()` rebuilt the reference column's width from the shell's edges, so it did not see the 32px of right padding on the legacy layout's inner grid. In that layout a drag, or a restore of saved widths, left the reference at about 328px instead of 360px. It now reads the reference column's own width. The fit loop looks again after each cut, because the reference stops at zero and a window far too small for the saved widths reports only part of the shortage. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * Centre the legacy sidebar handle on the sidebar's 1px border The sidebar's border is one pixel wide and ends on the column's edge, so its centre is half a pixel inside the edge. The 4px bar and the 2px line were centred on the edge, which left the border overhanging by one pixel on the left and two on the right. The sidebar handle now uses a 5px bar (7px on hover) and a 3px line, all placed on whole pixels around the border's own column. Co-Authored-By: Claude Sonnet 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 bd4f92e commit fa01507

6 files changed

Lines changed: 764 additions & 0 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { clampWidth, initApiColumnResize } from './api-column-resize'
2+
3+
const rect = (left: number, width: number) =>
4+
({ left, right: left + width, width }) as DOMRect
5+
6+
// The columns report the widths the script applied through its custom properties, as the real layout does, and the reference takes
7+
// what is left of the shell. The rail's right edge stays where it is, so growing it moves its left edge. `inset` is space the
8+
// columns lose beyond the two 32px gaps, such as the legacy grid's right padding.
9+
function mountShell({ width = 1600, nav = 300, rail = 640, inset = 0 } = {}) {
10+
document.body.innerHTML = `<div class="api-page-shell layout--three">
11+
<aside class="layout__nav"></aside>
12+
<main class="layout__content"></main>
13+
<aside class="api-page-rail"></aside>
14+
</div>`
15+
const shell = document.querySelector<HTMLElement>('.api-page-shell')!
16+
const applied = (name: string, fallback: number) =>
17+
parseFloat(shell.style.getPropertyValue(name)) || fallback
18+
const navWidth = () => applied('--layout-nav-width', nav)
19+
const railWidth = () => applied('--layout-rail-width', rail)
20+
shell.getBoundingClientRect = () => rect(0, width)
21+
document.querySelector('.layout__nav')!.getBoundingClientRect = () =>
22+
rect(0, navWidth())
23+
document.querySelector('.layout__content')!.getBoundingClientRect = () =>
24+
rect(
25+
navWidth() + 32,
26+
Math.max(0, width - navWidth() - railWidth() - 64 - inset)
27+
)
28+
document.querySelector('.api-page-rail')!.getBoundingClientRect = () =>
29+
rect(1000 + rail - railWidth(), railWidth())
30+
return shell
31+
}
32+
33+
const handle = (side: string) =>
34+
document.querySelector<HTMLElement>(`[data-api-column-handle="${side}"]`)!
35+
36+
describe('clampWidth', () => {
37+
it('keeps a width inside its limits', () => {
38+
expect(clampWidth(100, 200, 560)).toBe(200)
39+
expect(clampWidth(900, 200, 560)).toBe(560)
40+
expect(clampWidth(300.4, 200, 560)).toBe(300)
41+
})
42+
})
43+
44+
let disconnects = 0
45+
46+
describe('initApiColumnResize', () => {
47+
beforeEach(() => {
48+
vi.unstubAllGlobals()
49+
disconnects = 0
50+
localStorage.clear()
51+
vi.stubGlobal(
52+
'ResizeObserver',
53+
class {
54+
observe() {}
55+
disconnect() {
56+
disconnects++
57+
}
58+
}
59+
)
60+
})
61+
62+
it('adds a separator for the sidebar and one for the examples rail', () => {
63+
mountShell()
64+
initApiColumnResize()
65+
66+
expect(handle('nav').getAttribute('role')).toBe('separator')
67+
expect(handle('rail').getAttribute('aria-label')).toBe(
68+
'Resize examples'
69+
)
70+
expect(handle('nav').style.left).toBe('303px')
71+
expect(handle('rail').style.left).toBe('984px')
72+
})
73+
74+
it('grows the sidebar with ArrowRight and the rail with ArrowLeft, and keeps both', () => {
75+
const shell = mountShell()
76+
initApiColumnResize()
77+
78+
handle('nav').dispatchEvent(
79+
new KeyboardEvent('keydown', { key: 'ArrowRight' })
80+
)
81+
handle('rail').dispatchEvent(
82+
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
83+
)
84+
85+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('324px')
86+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
87+
'664px'
88+
)
89+
expect(localStorage.getItem('api-column-widths')).toBe(
90+
'{"nav":324,"rail":664}'
91+
)
92+
})
93+
94+
it('restores saved widths and a double click gives the column back to the stylesheet', () => {
95+
localStorage.setItem('api-column-widths', '{"nav":340,"rail":50000}')
96+
const shell = mountShell({ width: 2000 })
97+
initApiColumnResize()
98+
99+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('340px')
100+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
101+
'900px'
102+
)
103+
104+
handle('rail').dispatchEvent(new MouseEvent('dblclick'))
105+
106+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe('')
107+
expect(localStorage.getItem('api-column-widths')).toBe('{"nav":340}')
108+
})
109+
110+
it('lets go of the replaced shell when a new one is mounted', () => {
111+
const remove = vi.spyOn(window, 'removeEventListener')
112+
mountShell()
113+
initApiColumnResize()
114+
disconnects = 0
115+
mountShell()
116+
initApiColumnResize()
117+
118+
expect(disconnects).toBe(1)
119+
expect(remove).toHaveBeenCalledWith('resize', expect.any(Function))
120+
remove.mockRestore()
121+
})
122+
123+
it('lets go of the shell when navigation leaves the API pages', () => {
124+
const remove = vi.spyOn(window, 'removeEventListener')
125+
mountShell()
126+
initApiColumnResize()
127+
disconnects = 0
128+
document.body.innerHTML = '<p>A documentation page</p>'
129+
initApiColumnResize()
130+
131+
expect(disconnects).toBe(1)
132+
expect(remove).toHaveBeenCalledWith('resize', expect.any(Function))
133+
remove.mockRestore()
134+
})
135+
136+
it('shrinks saved widths, rail first, when the window is too narrow for them', () => {
137+
localStorage.setItem('api-column-widths', '{"nav":560,"rail":900}')
138+
const shell = mountShell({ width: 1000 })
139+
initApiColumnResize()
140+
141+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
142+
'320px'
143+
)
144+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('256px')
145+
expect(localStorage.getItem('api-column-widths')).toBe(
146+
'{"nav":560,"rail":900}'
147+
)
148+
})
149+
150+
it('leaves the stylesheet widths alone below the breakpoint that shows the handles', () => {
151+
vi.stubGlobal('matchMedia', () => ({ matches: false }))
152+
localStorage.setItem('api-column-widths', '{"nav":560,"rail":900}')
153+
const shell = mountShell()
154+
initApiColumnResize()
155+
156+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('')
157+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe('')
158+
})
159+
160+
it('stops a key step at the width that keeps the reference at its minimum, across both columns', () => {
161+
const shell = mountShell({ width: 1380 })
162+
initApiColumnResize()
163+
164+
handle('rail').dispatchEvent(
165+
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
166+
)
167+
handle('nav').dispatchEvent(
168+
new KeyboardEvent('keydown', { key: 'ArrowRight' })
169+
)
170+
171+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
172+
'656px'
173+
)
174+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('300px')
175+
})
176+
177+
it('counts the space the columns lose beyond the gaps when it measures the room', () => {
178+
const shell = mountShell({ width: 1380, inset: 32 })
179+
initApiColumnResize()
180+
181+
handle('rail').dispatchEvent(
182+
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
183+
)
184+
185+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
186+
'640px'
187+
)
188+
})
189+
190+
it('stops a drag at the width that keeps the reference at its minimum', () => {
191+
const shell = mountShell()
192+
initApiColumnResize()
193+
194+
handle('rail').dispatchEvent(
195+
new MouseEvent('pointerdown', { clientX: 2000 })
196+
)
197+
handle('rail').dispatchEvent(
198+
new MouseEvent('pointermove', { clientX: 0 })
199+
)
200+
handle('rail').dispatchEvent(new MouseEvent('pointerup'))
201+
202+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
203+
'876px'
204+
)
205+
})
206+
207+
it('follows a drag and saves the width only when the drag ends', () => {
208+
const shell = mountShell()
209+
initApiColumnResize()
210+
211+
handle('rail').dispatchEvent(
212+
new MouseEvent('pointerdown', { clientX: 100 })
213+
)
214+
handle('rail').dispatchEvent(
215+
new MouseEvent('pointermove', { clientX: 60 })
216+
)
217+
218+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
219+
'680px'
220+
)
221+
expect(handle('rail').dataset.dragging).toBe('')
222+
expect(localStorage.getItem('api-column-widths')).toBeNull()
223+
224+
handle('rail').dispatchEvent(new MouseEvent('pointerup'))
225+
226+
expect(handle('rail').dataset.dragging).toBeUndefined()
227+
expect(localStorage.getItem('api-column-widths')).toBe('{"rail":680}')
228+
})
229+
230+
it('does not move a sidebar that sits at the design-system floor when a drag starts', () => {
231+
const shell = mountShell({ nav: 224 })
232+
initApiColumnResize()
233+
234+
handle('nav').dispatchEvent(
235+
new MouseEvent('pointerdown', { clientX: 100 })
236+
)
237+
handle('nav').dispatchEvent(
238+
new MouseEvent('pointermove', { clientX: 100 })
239+
)
240+
241+
expect(shell.style.getPropertyValue('--layout-nav-width')).toBe('224px')
242+
})
243+
244+
it('does not drag the examples rail under 320px', () => {
245+
const shell = mountShell()
246+
initApiColumnResize()
247+
248+
handle('rail').dispatchEvent(
249+
new MouseEvent('pointerdown', { clientX: 100 })
250+
)
251+
handle('rail').dispatchEvent(
252+
new MouseEvent('pointermove', { clientX: 1500 })
253+
)
254+
255+
expect(shell.style.getPropertyValue('--layout-rail-width')).toBe(
256+
'320px'
257+
)
258+
})
259+
})

0 commit comments

Comments
 (0)