Skip to content

Commit d22f1fe

Browse files
Responsive grid layout: fixed TopBar/Sidebar with document scroll
Switch from a CSS grid layout (where TopBar and Sidebar occupied grid cells alongside ContentPane) to fixed-position TopBar and Sidebar with document-level scroll. This is the foundation for mobile/tablet support. Key changes: - viewport meta tag: proper responsive instead of fixed 1050px - CSS: add --sidebar-width var, responsive --content-gutter, remove body overflow-y-hidden, set html/body/#root height: 100% - TopBar: single fixed element (was two grid cell fragments) - Sidebar: fixed position, hidden below 1000px - ContentPane: margin-left for sidebar on desktop, document scroll - Scroll restoration: window.scrollY instead of container scrollTop - PageSkeleton: match new fixed layout structure Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1cc2ce6 commit d22f1fe

10 files changed

Lines changed: 67 additions & 83 deletions

File tree

app/components/PageSkeleton.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import { useLocation } from 'react-router'
1010

11-
import { PageContainer } from '~/layouts/helpers'
1211
import { classed } from '~/util/classed'
1312

1413
import { MswBanner } from './MswBanner'
@@ -27,19 +26,23 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
2726
return (
2827
<>
2928
{process.env.MSW_BANNER ? <MswBanner disableButton /> : null}
30-
<PageContainer>
31-
<div className="border-secondary flex items-center gap-2 border-r border-b p-3">
32-
<Block className="h-8 w-8" />
33-
<Block className="h-4 w-24" />
34-
</div>
35-
<div className="border-secondary flex items-center justify-between gap-2 border-b p-3">
36-
<Block className="h-4 w-24" />
37-
<div className="flex items-center gap-2">
38-
<Block className="h-6 w-16" />
39-
<Block className="h-6 w-32" />
29+
<div className="min-h-full pt-(--top-bar-height)">
30+
{/* TopBar skeleton */}
31+
<div className="bg-default border-secondary fixed top-0 right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b">
32+
<div className="border-secondary flex items-center gap-2 border-r p-3">
33+
<Block className="h-8 w-8" />
34+
<Block className="h-4 w-24" />
35+
</div>
36+
<div className="flex items-center justify-between gap-2 p-3">
37+
<Block className="h-4 w-24" />
38+
<div className="flex items-center gap-2">
39+
<Block className="h-6 w-16" />
40+
<Block className="h-6 w-32" />
41+
</div>
4042
</div>
4143
</div>
42-
<div className="border-secondary border-r p-4">
44+
{/* Sidebar skeleton */}
45+
<div className="border-secondary fixed top-(--top-bar-height) bottom-0 left-0 w-(--sidebar-width) border-r p-4">
4346
<Block className="mb-10 h-4 w-full" />
4447
<div className="mb-6 space-y-2">
4548
<Block className="h-4 w-32" />
@@ -52,8 +55,9 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
5255
<Block className="h-4 w-14" />
5356
</div>
5457
</div>
55-
<div className="light:bg-raise" />
56-
</PageContainer>
58+
{/* Content skeleton */}
59+
<div className="light:bg-raise ml-(--sidebar-width)" />
60+
</div>
5761
</>
5862
)
5963
}

app/components/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const JumpToButton = () => {
6262

6363
export function Sidebar({ children }: { children: React.ReactNode }) {
6464
return (
65-
<div className="text-sans-md text-raise border-secondary flex flex-col border-r">
65+
<div className="text-sans-md text-raise border-secondary fixed top-(--top-bar-height) bottom-0 left-0 flex w-(--sidebar-width) flex-col overflow-y-auto border-r">
6666
<div className="mx-3 mt-4">
6767
<JumpToButton />
6868
</div>

app/components/TopBar.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,12 @@ import { pb } from '~/util/path-builder'
3232

3333
export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
3434
const { me } = useCurrentUser()
35-
// The height of this component is governed by the `PageContainer`
36-
// It's important that this component returns two distinct elements (wrapped in a fragment).
37-
// Each element will occupy one of the top column slots provided by `PageContainer`.
3835
return (
39-
<>
40-
<div className="border-secondary flex items-center border-r border-b px-2">
36+
<div className="bg-default border-secondary fixed top-0 right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b">
37+
<div className="border-secondary flex items-center border-r px-2">
4138
<HomeButton level={systemOrSilo} />
4239
</div>
43-
{/* Height is governed by PageContainer grid */}
44-
<div className="bg-default border-secondary flex items-center justify-between gap-4 border-b px-3">
40+
<div className="flex items-center justify-between gap-4 px-3">
4541
<div className="flex flex-1 gap-2.5">
4642
<Breadcrumbs />
4743
</div>
@@ -50,7 +46,7 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
5046
<UserMenu />
5147
</div>
5248
</div>
53-
</>
49+
</div>
5450
)
5551
}
5652

app/hooks/use-scroll-restoration.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,19 @@ function setScrollPosition(key: string, pos: number) {
1818
}
1919

2020
/**
21-
* Given a ref to a scrolling container element, keep track of its scroll
22-
* position before navigation and restore it on return (e.g., back/forward nav).
23-
* Note that `location.key` is used in the cache key, not `location.pathname`,
24-
* so the same path navigated to at different points in the history stack will
25-
* not share the same scroll position.
21+
* Keep track of window scroll position before navigation and restore it on
22+
* return (e.g., back/forward nav). Note that `location.key` is used in the
23+
* cache key, not `location.pathname`, so the same path navigated to at
24+
* different points in the history stack will not share the same scroll position.
2625
*/
27-
export function useScrollRestoration(container: React.RefObject<HTMLElement | null>) {
26+
export function useScrollRestoration() {
2827
const key = `scroll-position-${useLocation().key}`
2928
const { state } = useNavigation()
3029
useEffect(() => {
3130
if (state === 'loading') {
32-
setScrollPosition(key, container.current?.scrollTop ?? 0)
31+
setScrollPosition(key, window.scrollY)
3332
} else if (state === 'idle') {
34-
container.current?.scrollTo(0, getScrollPosition(key))
33+
window.scrollTo(0, getScrollPosition(key))
3534
}
36-
}, [key, state, container])
35+
}, [key, state])
3736
}

app/layouts/helpers.tsx

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { useRef } from 'react'
98
import { Outlet } from 'react-router'
109

1110
import { PageActionsTarget } from '~/components/PageActions'
@@ -14,15 +13,13 @@ import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
1413
import { SkipLinkTarget } from '~/ui/lib/SkipLink'
1514
import { classed } from '~/util/classed'
1615

17-
export const PageContainer = classed.div`grid h-screen grid-cols-[14.25rem_1fr] grid-rows-[var(--top-bar-height)_1fr]`
16+
export const PageContainer = classed.div`min-h-full pt-(--top-bar-height)`
1817

1918
export function ContentPane() {
20-
const ref = useRef<HTMLDivElement>(null)
21-
useScrollRestoration(ref)
19+
useScrollRestoration()
2220
return (
2321
<div
24-
ref={ref}
25-
className="light:bg-raise flex flex-col overflow-auto"
22+
className="light:bg-raise ml-(--sidebar-width) flex min-h-[calc(100vh-var(--top-bar-height))] flex-col"
2623
id="scroll-container"
2724
data-testid="scroll-container"
2825
>
@@ -47,12 +44,10 @@ export function ContentPane() {
4744
* `<div>` because we don't need it.
4845
*/
4946
export const SerialConsoleContentPane = () => (
50-
<div className="flex flex-col overflow-auto">
51-
<div className="flex grow flex-col">
52-
<SkipLinkTarget />
53-
<main className="*:gutter h-full">
54-
<Outlet />
55-
</main>
56-
</div>
47+
<div className="ml-(--sidebar-width) flex h-[calc(100vh-var(--top-bar-height))] flex-col overflow-hidden">
48+
<SkipLinkTarget />
49+
<main className="*:gutter h-full">
50+
<Outlet />
51+
</main>
5752
</div>
5853
)

app/table/QueryTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ function useScrollReset(triggerDep: string | undefined) {
4646
const resetRequested = useRef(false)
4747
useEffect(() => {
4848
if (resetRequested.current) {
49-
document.querySelector('#scroll-container')?.scrollTo(0, 0)
49+
window.scrollTo(0, 0)
5050
resetRequested.current = false
5151
}
5252
}, [triggerDep])

app/ui/styles/index.css

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
:root {
129129
--content-gutter: 2.5rem;
130130
--top-bar-height: 54px;
131+
--sidebar-width: 14.25rem;
131132

132133
/* Nicer easing from: https://twitter.com/bdc */
133134
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
@@ -154,8 +155,21 @@
154155
}
155156

156157
@layer base {
158+
html,
159+
body,
160+
#root {
161+
height: 100%;
162+
}
163+
157164
body {
158-
@apply text-default bg-default overflow-y-hidden font-sans;
165+
@apply text-default bg-default font-sans;
166+
overscroll-behavior-y: none;
167+
}
168+
169+
@media (max-width: 999px) {
170+
:root {
171+
--content-gutter: 1.5rem;
172+
}
159173
}
160174

161175
/* https://github.com/tailwindlabs/tailwindcss/blob/v2.2.4/src/plugins/css/preflight.css#L57 */

index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
<meta charset="utf-8" />
1313
<title>Oxide Console</title>
1414

15-
<meta name="viewport" content="width=1050" />
15+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1616
<meta name="color-scheme" content="dark light" />
1717

1818
<link rel="icon" type="image/svg+xml" href="./app/assets/favicon.svg" />

test/e2e/scroll-restore.e2e.ts

Lines changed: 9 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { expect, test } from '@playwright/test'
1010
import { expectScrollTop, scrollTo, sleep } from './utils'
1111

1212
test('scroll restore', async ({ page }) => {
13-
// open small window to make scrolling easier
14-
await page.setViewportSize({ width: 800, height: 500 })
13+
// use desktop-width viewport with short height to make scrolling easier
14+
await page.setViewportSize({ width: 1280, height: 400 })
1515

1616
// nav to disks and scroll it
1717
await page.goto('/projects/mock-project/disks')
18+
// wait for content to render so the page is tall enough to scroll
19+
await page.getByRole('heading', { name: 'Disks' }).waitFor()
1820
await expectScrollTop(page, 0)
1921
await scrollTo(page, 143)
2022

@@ -32,43 +34,19 @@ test('scroll restore', async ({ page }) => {
3234
await scrollTo(page, 190)
3335
await sleep(1000)
3436

35-
// go forward to snapshots, now scroll it
36-
await page.goForward()
37+
// new nav to snapshots via click, scroll it
38+
await page.getByRole('link', { name: 'Snapshots' }).click()
3739
await expect(page).toHaveURL('/projects/mock-project/snapshots')
3840
await expectScrollTop(page, 0)
39-
await scrollTo(page, 30)
40-
41-
// Oddly, this is required here in order for the page to have time to
42-
// catch the 30 scroll position. This became necessary with RR v7's use of
43-
// startTransition. Extra oddly, with a value of 500 it passes rarely, but
44-
// with 1000 it passes every time.
45-
await sleep(1000)
46-
47-
// new nav to disks
48-
await page.getByRole('link', { name: 'Disks' }).click()
49-
await expectScrollTop(page, 0)
50-
51-
// this is too flaky so forget it for now
5241

53-
// random reload in there because we use sessionStorage. note we are
54-
// deliberately on the disks page here because there's a quirk in playwright
55-
// that seems to reset to the disks page on reload
56-
// await page.reload()
57-
58-
// back to snapshots, scroll is restored
59-
await page.goBack()
60-
await expect(page).toHaveURL('/projects/mock-project/snapshots')
61-
await expectScrollTop(page, 30)
62-
63-
// back again to disks, newer scroll value is restored
42+
// back to disks, newer scroll value is restored
6443
await page.goBack()
6544
await expect(page).toHaveURL('/projects/mock-project/disks')
6645
await sleep(1000)
6746
await expectScrollTop(page, 190)
6847

69-
// forward again to newest disks history entry, scroll remains 0
48+
// forward to snapshots, scroll is 0 (fresh nav)
7049
await page.goForward()
71-
await page.goForward()
72-
await expect(page).toHaveURL('/projects/mock-project/disks')
50+
await expect(page).toHaveURL('/projects/mock-project/snapshots')
7351
await expectScrollTop(page, 0)
7452
})

test/e2e/utils.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,14 +265,12 @@ export async function chooseFile(input: Locator, size: 'large' | 'small' = 'larg
265265
}
266266

267267
export async function expectScrollTop(page: Page, expected: number) {
268-
const container = page.getByTestId('scroll-container')
269-
const getScrollTop = () => container.evaluate((el: HTMLElement) => el.scrollTop)
268+
const getScrollTop = () => page.evaluate(() => window.scrollY)
270269
await expect.poll(getScrollTop).toBe(expected)
271270
}
272271

273272
export async function scrollTo(page: Page, to: number) {
274-
const container = page.getByTestId('scroll-container')
275-
await container.evaluate((el: HTMLElement, to) => el.scrollTo(0, to), to)
273+
await page.evaluate((to) => window.scrollTo(0, to), to)
276274
}
277275

278276
export async function addTlsCert(page: Page) {

0 commit comments

Comments
 (0)