Skip to content
Merged
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 @@ -115,7 +115,7 @@
# Layout & accessibility

- Build pages inside the shared `PageContainer`/`ContentPane` (`app/layouts/helpers.tsx`).
- Surface page-level buttons and pagination via the `PageActions` and `Pagination` tunnels from `tunnel-rat`; anything rendered through `.In` lands in `.Target` automatically.
- To put page-level buttons or pagination in the sticky footer at the bottom of `ContentPane`, wrap them in `PageFooter` (`app/components/PageFooter.tsx`), which portals its children there. `FullPageForm` actions and the `Pagination` component already do this.
- 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
13 changes: 0 additions & 13 deletions app/components/PageActions.tsx

This file was deleted.

26 changes: 26 additions & 0 deletions app/components/PageFooter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { create } from 'zustand'

/** The sticky footer element at the bottom of `ContentPane` */
const useFooter = create<{ el: HTMLElement | null }>(() => ({ el: null }))

/** Ref callback for the footer element */
export const setPageFooter = (el: HTMLElement | null) => useFooter.setState({ el })

/**
* Render page-level buttons or pagination in the sticky footer at the bottom
* of the content pane, outside the page's scrolling content.
*/
export function PageFooter({ children }: { children: ReactNode }) {
const footer = useFooter((s) => s.el)
// null on the first render of a ContentPane, before the footer has mounted
return footer && createPortal(children, footer)
}
10 changes: 3 additions & 7 deletions app/components/Pagination.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,17 @@
*
* Copyright Oxide Computer Company
*/
import tunnel from 'tunnel-rat'

import {
Pagination as UIPagination,
type PaginationProps as UIPaginationProps,
} from '~/ui/lib/Pagination'

const Tunnel = tunnel()
import { PageFooter } from './PageFooter'

export function Pagination(props: UIPaginationProps) {
return (
<Tunnel.In>
<PageFooter>
<UIPagination className="gutter h-14 py-5" {...props} />
</Tunnel.In>
</PageFooter>
)
}

Pagination.Target = Tunnel.Out
6 changes: 3 additions & 3 deletions app/components/form/FullPageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { flattenChildren, pluckFirstOfType } from '~/util/children'
import { classed } from '~/util/classed'

import { Form } from '../form/Form'
import { PageActions } from '../PageActions'
import { PageFooter } from '../PageFooter'

interface FullPageFormProps<TFieldValues extends FieldValues> {
id: string
Expand Down Expand Up @@ -106,7 +106,7 @@ export function FullPageForm<TFieldValues extends FieldValues>({
{!isSubmitSuccessful && <ConfirmNavigation blocker={blocker} />}

{actions && (
<PageActions>
<PageFooter>
<PageActionsContainer>
{cloneElement(actions, {
formId: id,
Expand All @@ -115,7 +115,7 @@ export function FullPageForm<TFieldValues extends FieldValues>({
error: error || submitError,
})}
</PageActionsContainer>
</PageActions>
</PageFooter>
)}
</>
)
Expand Down
12 changes: 6 additions & 6 deletions app/layouts/helpers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
*/
import { Outlet } from 'react-router'

import { PageActionsTarget } from '~/components/PageActions'
import { Pagination } from '~/components/Pagination'
import { setPageFooter } from '~/components/PageFooter'
import { useScrollRestoration } from '~/hooks/use-scroll-restoration'
import { classed } from '~/util/classed'

Expand Down Expand Up @@ -38,10 +37,11 @@ export function ContentPane() {
<Outlet />
</main>
</div>
<div className="bg-default border-secondary sticky bottom-0 z-(--z-top-bar) shrink-0 justify-between overflow-hidden border-t empty:border-t-0">
<Pagination.Target />
<PageActionsTarget />
</div>
{/* pagination and form actions portal in here through PageFooter */}
<div
ref={setPageFooter}
className="bg-default border-secondary sticky bottom-0 z-(--z-top-bar) shrink-0 justify-between overflow-hidden border-t empty:border-t-0"
/>
</div>
)
}
Expand Down
34 changes: 0 additions & 34 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@
"shiki": "^4.5.0",
"simplebar-react": "^3.2.6",
"ts-pattern": "^5.8.0",
"tunnel-rat": "^0.1.2",
"uplot": "^1.6.32",
"uplot-react": "^1.2.4",
"use-debounce": "^10.0.4",
Expand Down
24 changes: 24 additions & 0 deletions test/e2e/pagination.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,27 @@ test('pagination', async ({ page }) => {
await nextButton.click()
await expect(spinner).toBeHidden({ timeout: 10 }) // no spinner, cached page
})

// Pagination and full-page form buttons are portaled into the same footer
// element, which is replaced when the layout changes
test('footer content follows navigation', async ({ page }) => {
const pagination = page.getByRole('navigation', { name: 'Pagination' })
const submit = page.getByRole('button', { name: 'Create instance' })

await page.goto('/projects')
await expect(pagination).toBeVisible()

// silo layout to project layout
await page.getByRole('link', { name: 'mock-project' }).click()
await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible()
await expect(pagination).toBeVisible()

await page.getByRole('link', { name: 'New Instance' }).click()
await expect(submit).toBeVisible()
await expect(pagination).toBeHidden()

await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('heading', { name: 'Instances' })).toBeVisible()
await expect(pagination).toBeVisible()
await expect(submit).toBeHidden()
})
Loading