Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@

# Layout & accessibility

- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration tied to `#scroll-container` (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`).
- Build pages inside the shared `PageContainer`/`ContentPane` so you inherit the skip link, sticky footer, pagination target, and scroll restoration (`app/layouts/helpers.tsx`, `app/hooks/use-scroll-restoration.ts`).
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
- For global loading states, reuse `PageSkeleton`—it keeps the MSW banner and grid layout stable, and `skipPaths` lets you opt-out for routes with custom layouts (`app/components/PageSkeleton.tsx`).
- Enforce accessibility at the type level: use `AriaLabel` type from `app/ui/util/aria.ts` which requires exactly one of `aria-label` or `aria-labelledby` on custom interactive components.
Expand Down
29 changes: 17 additions & 12 deletions app/components/PageSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { useLocation } from 'react-router'

import { PageContainer } from '~/layouts/helpers'
import { ContentPane, PageContainer } from '~/layouts/helpers'
import { classed } from '~/util/classed'

import { MswBanner } from './MswBanner'
Expand All @@ -28,18 +28,22 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
<>
{process.env.MSW_BANNER ? <MswBanner disableButton /> : null}
<PageContainer>
<div className="border-secondary flex items-center gap-2 border-r border-b p-3">
<Block className="h-8 w-8" />
<Block className="h-4 w-24" />
</div>
<div className="border-secondary flex items-center justify-between gap-2 border-b p-3">
<Block className="h-4 w-24" />
<div className="flex items-center gap-2">
<Block className="h-6 w-16" />
<Block className="h-6 w-32" />
{/* TopBar */}
<div className="bg-default border-secondary fixed inset-x-0 top-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b">
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an argument to say we should define the shared classes between the skeleton and regular layout to avoid drift but the juice might not be worth the squeeze.

<div className="border-secondary flex items-center gap-2 border-r p-3">
<Block className="h-8 w-8" />
<Block className="h-4 w-24" />
</div>
<div className="flex items-center justify-between gap-2 p-3">
<Block className="h-4 w-24" />
<div className="flex items-center gap-2">
<Block className="h-6 w-16" />
<Block className="h-6 w-32" />
</div>
</div>
</div>
<div className="border-secondary border-r p-4">
{/* Sidebar */}
<div className="border-secondary fixed top-(--top-bar-height) bottom-0 left-0 w-(--sidebar-width) border-r p-4">
<Block className="mb-10 h-4 w-full" />
<div className="mb-6 space-y-2">
<Block className="h-4 w-32" />
Expand All @@ -52,7 +56,8 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
<Block className="h-4 w-14" />
</div>
</div>
<div className="light:bg-raise" />
{/* Content */}
<ContentPane />
</PageContainer>
</>
)
Expand Down
2 changes: 1 addition & 1 deletion app/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const JumpToButton = () => {

export function Sidebar({ children }: { children: React.ReactNode }) {
return (
<div className="text-sans-md text-raise border-secondary flex flex-col border-r">
<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 overscroll-none border-r">
<div className="mx-3 mt-4">
<JumpToButton />
</div>
Expand Down
16 changes: 6 additions & 10 deletions app/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,12 @@ import { pb } from '~/util/path-builder'

export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
const { me } = useCurrentUser()
// The height of this component is governed by the `PageContainer`
// It's important that this component returns two distinct elements (wrapped in a fragment).
// Each element will occupy one of the top column slots provided by `PageContainer`.
return (
<>
<div className="border-secondary flex items-center border-r border-b px-2">
<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">
<div className="border-secondary flex items-center border-r px-2">
<HomeButton level={systemOrSilo} />
</div>
{/* Height is governed by PageContainer grid */}
<div className="bg-default border-secondary flex items-center justify-between gap-4 border-b px-3">
<div className="flex items-center justify-between gap-4 px-3">
<div className="flex flex-1 gap-2.5">
<Breadcrumbs />
</div>
Expand All @@ -50,7 +46,7 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) {
<UserMenu />
</div>
</div>
</>
</div>
)
}

Expand Down Expand Up @@ -146,7 +142,7 @@ function UserMenu() {
</span>
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content gap={8}>
<DropdownMenu.Content gap={8} zIndex="topBar">
<DropdownMenu.LinkItem to={pb.profile()}>Settings</DropdownMenu.LinkItem>
<ThemeSubmenu />
<DropdownMenu.Item onSelect={() => logout.mutate({})} label="Sign out" />
Expand Down Expand Up @@ -238,7 +234,7 @@ function SiloSystemPicker({ level }: { level: 'silo' | 'system' }) {
<SelectArrows6Icon className="text-quaternary ml-3 w-1.5!" aria-hidden />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content className="mt-2" anchor="bottom start">
<DropdownMenu.Content className="mt-2" anchor="bottom start" zIndex="topBar">
<SystemSiloItem to={pb.silos()} label="System" isSelected={level === 'system'} />
<SystemSiloItem to={pb.projects()} label="Silo" isSelected={level === 'silo'} />
</DropdownMenu.Content>
Expand Down
17 changes: 8 additions & 9 deletions app/hooks/use-scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,19 @@ function setScrollPosition(key: string, pos: number) {
}

/**
* Given a ref to a scrolling container element, keep track of its scroll
* position before navigation and restore it on return (e.g., back/forward nav).
* Note that `location.key` is used in the cache key, not `location.pathname`,
* so the same path navigated to at different points in the history stack will
* not share the same scroll position.
* Keep track of window scroll position before navigation and restore it on
* return (e.g., back/forward nav). Note that `location.key` is used in the
* cache key, not `location.pathname`, so the same path navigated to at
* different points in the history stack will not share the same scroll position.
*/
export function useScrollRestoration(container: React.RefObject<HTMLElement | null>) {
export function useScrollRestoration() {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we even need this hook now? Since we're using a regular window scroll, perhaps we can use the built-in react-router one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to #2450 if I remove it I was unable to get react-router ScrollRestoration to work

const key = `scroll-position-${useLocation().key}`
const { state } = useNavigation()
useEffect(() => {
if (state === 'loading') {
setScrollPosition(key, container.current?.scrollTop ?? 0)
setScrollPosition(key, window.scrollY)
} else if (state === 'idle') {
container.current?.scrollTo(0, getScrollPosition(key))
window.scrollTo(0, getScrollPosition(key))
}
}, [key, state, container])
}, [key, state])
}
25 changes: 8 additions & 17 deletions app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*
* Copyright Oxide Computer Company
*/
import { useRef } from 'react'
import { Outlet } from 'react-router'

import { PageActionsTarget } from '~/components/PageActions'
Expand All @@ -14,18 +13,12 @@ import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
import { SkipLinkTarget } from '~/ui/lib/SkipLink'
import { classed } from '~/util/classed'

export const PageContainer = classed.div`grid h-screen grid-cols-[14.25rem_1fr] grid-rows-[var(--top-bar-height)_1fr]`
export const PageContainer = classed.div`min-h-full pt-(--top-bar-height)`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Top bar is fixed, this leaves space for it. Alternatively we use sticky for the top bar but the sidebar would still be fixed and this feels marginally cleaner.


export function ContentPane() {
const ref = useRef<HTMLDivElement>(null)
useScrollRestoration(ref)
useScrollRestoration()
return (
<div
ref={ref}
className="light:bg-raise flex flex-col overflow-auto"
id="scroll-container"
data-testid="scroll-container"
>
<div className="light:bg-raise ml-(--sidebar-width) flex min-h-[calc(100vh-var(--top-bar-height))] flex-col">
<div className="flex grow flex-col pb-8">
<SkipLinkTarget />
<main className="*:gutter">
Expand All @@ -47,12 +40,10 @@ export function ContentPane() {
* `<div>` because we don't need it.
*/
export const SerialConsoleContentPane = () => (
<div className="flex flex-col overflow-auto">
<div className="flex grow flex-col">
<SkipLinkTarget />
<main className="*:gutter h-full">
<Outlet />
</main>
</div>
<div className="ml-(--sidebar-width) flex h-[calc(100vh-var(--top-bar-height))] flex-col overflow-hidden">
<SkipLinkTarget />
<main className="*:gutter h-full">
<Outlet />
</main>
</div>
)
2 changes: 1 addition & 1 deletion app/table/QueryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function useScrollReset(triggerDep: string | undefined) {
const resetRequested = useRef(false)
useEffect(() => {
if (resetRequested.current) {
document.querySelector('#scroll-container')?.scrollTo(0, 0)
window.scrollTo(0, 0)
resetRequested.current = false
}
}, [triggerDep])
Expand Down
6 changes: 5 additions & 1 deletion app/table/columns/action-col.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,11 @@ export const RowActions = ({ id, copyIdLabel = 'Copy ID', actions }: RowActionsP
<More12Icon />
</DropdownMenu.Trigger>
{/* offset moves menu in from the right so it doesn't align with the table border */}
<DropdownMenu.Content anchor={{ to: 'bottom end', offset: -6 }} className="-mt-2">
<DropdownMenu.Content
anchor={{ to: 'bottom end', offset: -6 }}
className="-mt-2"
collisionPadding={{ bottom: 56 }}
>
{id && <CopyIdItem id={id} label={copyIdLabel} />}
{actions?.map(({ className, ...action }) =>
'to' in action ? (
Expand Down
23 changes: 21 additions & 2 deletions app/ui/lib/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,43 @@ function parseAnchor(
return { side, align, sideOffset, alignOffset }
}

const zIndexClass = {
dropdown: 'z-(--z-content-dropdown)',
topBar: 'z-(--z-top-bar-dropdown)',
modal: 'z-(--z-modal-dropdown)',
sideModal: 'z-(--z-side-modal-dropdown)',
} as const

type ZIndex = keyof typeof zIndexClass

type ContentProps = {
className?: string
children: ReactNode
anchor?: AnchorProp
/** Spacing in px between trigger and menu */
gap?: 8
zIndex?: ZIndex
collisionPadding?: React.ComponentProps<typeof Menu.Positioner>['collisionPadding']
}

export function Content({ className, children, anchor = 'bottom end', gap }: ContentProps) {
export function Content({
className,
children,
anchor = 'bottom end',
gap,
zIndex = 'dropdown',
collisionPadding,
}: ContentProps) {
const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap)
return (
<Menu.Portal>
<Menu.Positioner
className="z-(--z-top-bar-dropdown)"
className={zIndexClass[zIndex]}
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
collisionPadding={collisionPadding}
>
<Menu.Popup
className={cn('dropdown-menu-content shadow-menu outline-hidden', className)}
Expand Down
2 changes: 1 addition & 1 deletion app/ui/lib/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function Modal({
)
}

Modal.Body = classed.div`py-2 overflow-y-auto`
Modal.Body = classed.div`py-2 overflow-y-auto overscroll-none`

Modal.Section = classed.div`p-4 space-y-4 border-b border-secondary text-default last-of-type:border-none text-sans-md`

Expand Down
2 changes: 1 addition & 1 deletion app/ui/lib/SideModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ function SideModalBody({ children }: { children?: ReactNode }) {
<div
ref={overflowRef}
className={cn(
'body relative h-full overflow-y-auto pt-8 pb-12',
'body relative h-full overflow-y-auto pt-8 pb-12 overscroll-none',
!scrollStart && 'border-t-secondary border-t'
)}
data-testid="sidemodal-scroll-container"
Expand Down
17 changes: 16 additions & 1 deletion app/ui/styles/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,16 @@
:root {
--content-gutter: 2.5rem;
--top-bar-height: 54px;
--sidebar-width: 14.25rem;

@media (max-width: 767px) {
:root {
--content-gutter: 1.5rem;
}
}
}

:root {
/* Nicer easing from: https://twitter.com/bdc */
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
Expand All @@ -154,8 +163,14 @@
}

@layer base {
html,
body,
#root {
height: 100%;
}

body {
@apply text-default bg-default overflow-y-hidden font-sans;
@apply text-default bg-default font-sans;
}

/* https://github.com/tailwindlabs/tailwindcss/blob/v2.2.4/src/plugins/css/preflight.css#L57 */
Expand Down
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<meta charset="utf-8" />
<title>Oxide Console</title>

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

<link rel="icon" type="image/svg+xml" href="./app/assets/favicon.svg" />
Expand Down
4 changes: 2 additions & 2 deletions test/e2e/ip-pool-silo-config.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => {

// RHF required validation should show an error on the pool field
await expect(
page.getByTestId('scroll-container').getByText('IPv4 pool is required')
page.locator('#create-instance-form').getByText('IPv4 pool is required')
).toBeVisible()

// Should still be on the create page
Expand Down Expand Up @@ -376,7 +376,7 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => {

// RHF required validation should show an error on the pool field
await expect(
page.getByTestId('scroll-container').getByText('IPv6 pool is required')
page.locator('#create-instance-form').getByText('IPv6 pool is required')
).toBeVisible()

// Should still be on the create page
Expand Down
Loading
Loading