From 72dc09672ae3ba9755f0ab47fee889f116ec3aa0 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 11 Nov 2026 15:08:15 -0600 Subject: [PATCH] Inline tunnel-rat --- AGENTS.md | 2 +- app/components/PageActions.tsx | 13 ----------- app/components/PageFooter.tsx | 26 +++++++++++++++++++++ app/components/Pagination.tsx | 10 +++----- app/components/form/FullPageForm.tsx | 6 ++--- app/layouts/helpers.tsx | 12 +++++----- package-lock.json | 34 ---------------------------- package.json | 1 - test/e2e/pagination.e2e.ts | 24 ++++++++++++++++++++ 9 files changed, 63 insertions(+), 65 deletions(-) delete mode 100644 app/components/PageActions.tsx create mode 100644 app/components/PageFooter.tsx diff --git a/AGENTS.md b/AGENTS.md index 92ca0e08b8..b67c800bc7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/app/components/PageActions.tsx b/app/components/PageActions.tsx deleted file mode 100644 index 6960a286fe..0000000000 --- a/app/components/PageActions.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/* - * 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 tunnel from 'tunnel-rat' - -const Tunnel = tunnel() - -export const PageActions = Tunnel.In -export const PageActionsTarget = Tunnel.Out diff --git a/app/components/PageFooter.tsx b/app/components/PageFooter.tsx new file mode 100644 index 0000000000..3a5453effb --- /dev/null +++ b/app/components/PageFooter.tsx @@ -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) +} diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx index f6b795bcce..6cbf7c3e62 100644 --- a/app/components/Pagination.tsx +++ b/app/components/Pagination.tsx @@ -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 ( - + - + ) } - -Pagination.Target = Tunnel.Out diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index d6090c0bf9..c8aed64d0b 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -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 { id: string @@ -106,7 +106,7 @@ export function FullPageForm({ {!isSubmitSuccessful && } {actions && ( - + {cloneElement(actions, { formId: id, @@ -115,7 +115,7 @@ export function FullPageForm({ error: error || submitError, })} - + )} ) diff --git a/app/layouts/helpers.tsx b/app/layouts/helpers.tsx index 2a0815d8d8..75b814ed8a 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -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' @@ -38,10 +37,11 @@ export function ContentPane() { -
- - -
+ {/* pagination and form actions portal in here through PageFooter */} +
) } diff --git a/package-lock.json b/package-lock.json index ce7c9faabc..884eff9a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,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", @@ -7514,39 +7513,6 @@ "version": "1.14.1", "license": "0BSD" }, - "node_modules/tunnel-rat": { - "version": "0.1.2", - "license": "MIT", - "dependencies": { - "zustand": "^4.3.2" - } - }, - "node_modules/tunnel-rat/node_modules/zustand": { - "version": "4.5.6", - "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" - }, - "peerDependencies": { - "@types/react": ">=16.8", - "immer": ">=9.0.6", - "react": ">=16.8" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - } - } - }, "node_modules/type-fest": { "version": "5.2.0", "dev": true, diff --git a/package.json b/package.json index 8ec296feaf..5e0593de8c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/e2e/pagination.e2e.ts b/test/e2e/pagination.e2e.ts index a0eb9179f8..3b4aaa7625 100644 --- a/test/e2e/pagination.e2e.ts +++ b/test/e2e/pagination.e2e.ts @@ -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() +})