diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a1a7d415..70c00008 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -64,6 +64,7 @@ jobs:
npm run test:run -- src/testing/tests/FinchAppLayout.test.tsx
npm run test:run -- src/testing/tests/FinchHeader.test.tsx
npm run test:run -- src/testing/tests/FinchMainContent.test.tsx
+ npm run test:run -- src/testing/tests/FinchPageTabs.test.tsx
npm run test:run -- src/testing/tests/FinchSidebar.test.tsx
npm run test:run -- src/testing/tests/GoogleDoc.test.tsx
npm run test:run -- src/testing/tests/Header.test.tsx
@@ -89,6 +90,7 @@ jobs:
npm run test:run -- src/testing/tests/TiledComponents.test.tsx
npm run test:run -- src/testing/tests/TiledLinePlotMaker.test.tsx
npm run test:run -- src/testing/tests/Widget.test.tsx
+ npm run test:run -- src/testing/tests/useActiveRoute.test.tsx
echo "Running ophyd sim test suites..."
npm run test:run -- src/testing/tests/BeamstopModel.test.tsx
npm run test:run -- src/testing/tests/createFallbackTransport.test.ts
@@ -214,7 +216,7 @@ jobs:
`- ${testStepStatus} **Buttons** - Button, ButtonIconOnly, ButtonWithIcon, ButtonCopyToClipboard`,
`- ${testStepStatus} **Inputs** - InputCheckBox, InputNumber, InputEnumBoxRounded, InputStringBoxRounded`,
`- ${testStepStatus} **Plots** - PlotlyHeatmap, PlotlyHeatmapTiled, PlotlyScatter, Histogram, ColormapPicker`,
- `- ${testStepStatus} **Layout** - Sidebar, SidebarItem, FinchSidebar, FinchAppLayout, FinchHeader, FinchMainContent, Header, Main, Bento, Paper, Widget`,
+ `- ${testStepStatus} **Layout** - Sidebar, SidebarItem, FinchSidebar, FinchAppLayout, FinchHeader, FinchMainContent, FinchPageTabs, useActiveRoute, Header, Main, Bento, Paper, Widget`,
`- ${testStepStatus} **Tiled** - TiledComponents, TiledLinePlotMaker`,
`- ${testStepStatus} **Devices** - Camera, Shutter, DeviceControllerBox, DeviceControllerBoxSimple, TableDeviceController, ControllerAbsoluteMove, ControllerRelativeMove, Hexapod, BeamEnergy`,
`- ${testStepStatus} **Services** - QueueServer, SignalMonitorPlots, Experiment`,
diff --git a/README.md b/README.md
index 2806e2f6..d7abc15c 100644
--- a/README.md
+++ b/README.md
@@ -184,12 +184,72 @@ export default App;
```
Each route entry supports:
-- `path` — the URL path (e.g. `"/controls"`)
+- `path` — the URL path, static only (e.g. `"/controls"`). The leading slash is optional
- `label` — text shown in the sidebar navigation
-- `element` — the React component to render for that route
+- `element` — the React component to render for that route (omit when `tabs` is set)
+- `tabs` — optional tabs shown in a strip above the page, each with its own URL (omit `element` when set)
- `icon` — optional React element shown next to the label in the sidebar
- `isBackgroundTransparent` — when `true`, renders the page with a transparent background and white text as a default (good for separate components on the same page)
- `classNameContainer` — additional CSS classes for the page container
+- `showPageTitle` — whether this route's label appears in the header (defaults to `true`)
+
+Route paths must be static. The sidebar links to each `path` directly, so a dynamic segment such as `/runs/:uid` would render a link to that literal text.
+
+The header shows the active route's label after the app title, on every route. Pass `showPageTitle` to the layout to turn that off everywhere, and set `showPageTitle` on a route to override the layout either way. That is how you hide the label on a landing page whose app title already names it.
+
+### Page tabs
+
+A route can declare `tabs` instead of `element`. Finch draws a tab strip above the page and gives each tab its own URL beneath the route path, so tabs are deep-linkable and survive a refresh. Visiting the bare route path redirects to the first tab.
+
+```tsx
+const routes: RouteItem[] = [
+ {
+ path: '/explorer',
+ label: 'Explorer',
+ icon: ,
+ tabs: [
+ { path: 'live', label: 'Live', element: }, // -> /explorer/live
+ { path: 'replay', label: 'Replay', element: }, // -> /explorer/replay
+ ],
+ },
+];
+```
+
+Each tab takes `path`, `label`, and `element`, plus optional `isBackgroundTransparent` and `classNameContainer`. Both fall back to the parent route's values, and `classNameContainer` is merged on top of the route's rather than replacing it.
+
+A leading slash on a tab `path` is optional and ignored, so `'live'` and `'/live'` both land on `/explorer/live`. Trailing slashes are ignored too, repeated slashes collapse, and the route's own `path` works the same way. Case is ignored when matching, so someone who types `/Explorer/Live` lands on that same page.
+
+The root route can declare tabs, and they sit under a reserved `-` segment:
+
+```tsx
+const routes: RouteItem[] = [
+ {
+ path: '/',
+ label: 'Home',
+ tabs: [
+ { path: 'live', label: 'Live', element: }, // -> /-/live
+ { path: 'replay', label: 'Replay', element: }, // -> /-/replay
+ ],
+ },
+];
+```
+
+Visiting `/` redirects to `/-/live`. The `-` keeps the root's tabs out of the top level, where `/live` would shadow a real `/live` route and leave the sidebar with no entry highlighted. It also keeps the tab catch-all scoped, so an unknown `/-/…` URL falls back to the first tab while the rest of your URLs are untouched. Leave `-` to the root's tabs.
+
+Finch rejects a couple of configurations with an error rather than failing quietly.
+
+A tab `path` cannot be empty or a bare `/`. Every tab needs its own segment beneath the route, so give it something like `path: 'overview'`.
+
+No two pages can land on the same URL. Routes and tabs share one URL space, so a route at `/explorer/live` collides with the Live tab of `/explorer` exactly as two `live` tabs collide with each other. Because slashes are trimmed and case is ignored, `'live'`, `'/live'` and `'Live'` all mean `/explorer/live`, so this is an error:
+
+```tsx
+tabs: [
+ { path: 'live', label: 'Live', element: },
+ { path: '/live', label: 'Replay', element: }, // error: same URL as Live
+];
+```
+
+Without the check the Replay tab would be dead weight: both tabs would link to `/explorer/live`, both would draw as active, and only `` would ever render.
## Alternative Installation - Clone This Repo
diff --git a/src/app/App.tsx b/src/app/App.tsx
index 8f226cdb..a0e1af67 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -7,7 +7,7 @@ import AllComponentsPage from './pages/AllComponentsPage';
import TestPage from './pages/TestPage';
import Documentation from './pages/Documentation';
-import FinchAppLayout from '@/components/FinchAppLayout';
+import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout';
import { RouteItem } from '@/types/navigationRouterTypes';
diff --git a/src/components/FinchAppLayout.tsx b/src/components/FinchAppLayout/FinchAppLayout.tsx
similarity index 63%
rename from src/components/FinchAppLayout.tsx
rename to src/components/FinchAppLayout/FinchAppLayout.tsx
index 147fa9b9..65000883 100644
--- a/src/components/FinchAppLayout.tsx
+++ b/src/components/FinchAppLayout/FinchAppLayout.tsx
@@ -1,6 +1,7 @@
-import FinchHeader from '@/components/FinchHeader';
-import FinchMainContent from '@/components/FinchMainContent';
-import FinchSidebar from '@/components/FinchSidebar';
+import FinchHeader from './FinchHeader';
+import FinchMainContent from './FinchMainContent';
+import FinchSidebar from './FinchSidebar';
+import { useActiveRoute } from './hooks/useActiveRoute';
import { cn } from '@/lib/utils';
import { RouteItem } from '@/types/navigationRouterTypes';
@@ -10,8 +11,15 @@ export type FinchAppLayoutProps = {
routes: RouteItem[];
/** Title text displayed in the header. */
headerTitle?: string;
+ /**
+ * Whether the active route's label appears in the header, for every route.
+ * A route's own `showPageTitle` overrides this. Defaults to `true`.
+ */
+ showPageTitle?: boolean;
/** Additional CSS classes applied to the header title element. */
classNameHeaderTitle?: string;
+ /** Additional CSS classes applied to the header page title element. */
+ classNameHeaderPageTitle?: string;
/** URL of the logo image displayed in the header. Ignored when `headerLogoIcon` is provided. */
headerLogoUrl?: string;
/**
@@ -21,8 +29,16 @@ export type FinchAppLayoutProps = {
headerLogoIcon?: React.ReactElement;
/** Additional CSS classes applied to the outer main content area. */
classNameMainContent?: string;
+ /** Additional CSS classes applied to the scrolling main content area that holds the page padding. */
+ classNameMainContentScrollContainer?: string;
/** Additional CSS classes applied to the inner main content area. */
classNameMainContentInnerContainer?: string;
+ /** Additional CSS classes applied to the page tab strip. */
+ classNamePageTabs?: string;
+ /** Additional CSS classes applied to the active page tab. */
+ classNamePageTabsActive?: string;
+ /** Additional CSS classes applied to inactive page tabs. */
+ classNamePageTabsInactive?: string;
/** Additional CSS classes applied to the header element. */
classNameHeader?: string;
/** Additional CSS classes applied to the sidebar element. */
@@ -39,12 +55,18 @@ export type FinchAppLayoutProps = {
export default function FinchAppLayout({
routes,
headerTitle,
+ showPageTitle,
headerLogoUrl,
headerLogoIcon,
classNameMainContent,
+ classNameMainContentScrollContainer,
classNameMainContentInnerContainer,
+ classNamePageTabs,
+ classNamePageTabsActive,
+ classNamePageTabsInactive,
classNameHeader,
classNameHeaderTitle,
+ classNameHeaderPageTitle,
classNameSidebar,
classNameSidebarActiveLink,
classNameSidebarInactiveLink,
@@ -52,6 +74,10 @@ export default function FinchAppLayout({
className,
...props
}: FinchAppLayoutProps) {
+ const activeRoute = useActiveRoute(routes);
+ const isPageTitleShown = activeRoute?.showPageTitle ?? showPageTitle ?? true;
+ const pageTitle = isPageTitleShown ? activeRoute?.label : undefined;
+
return (
);
diff --git a/src/components/FinchHeader.tsx b/src/components/FinchAppLayout/FinchHeader.tsx
similarity index 72%
rename from src/components/FinchHeader.tsx
rename to src/components/FinchAppLayout/FinchHeader.tsx
index a058d5c8..d0dce3e2 100644
--- a/src/components/FinchHeader.tsx
+++ b/src/components/FinchAppLayout/FinchHeader.tsx
@@ -3,6 +3,8 @@ import { cn } from '@/lib/utils';
export type FinchHeaderProps = {
/** Title text displayed in the header. */
title?: string;
+ /** Name of the active page, rendered after the title and a divider. */
+ pageTitle?: string;
/** URL of the logo image displayed in the header. Ignored when `logoIcon` is provided. */
logoUrl?: string;
/**
@@ -16,6 +18,8 @@ export type FinchHeaderProps = {
classNameImage?: string;
/** Additional CSS classes applied to the title element. */
classNameTitle?: string;
+ /** Additional CSS classes applied to the page title element. */
+ classNamePageTitle?: string;
/** Arbitrary JSX rendered on the right side of the header. */
rightSlot?: React.ReactNode;
};
@@ -28,11 +32,13 @@ export type FinchHeaderProps = {
*/
export default function FinchHeader({
title = 'BEAMLINE APP',
+ pageTitle,
logoUrl = 'https://img.icons8.com/?size=100&id=11743&format=png&color=000000',
logoIcon,
className,
classNameImage,
classNameTitle,
+ classNamePageTitle,
rightSlot,
...props
}: FinchHeaderProps) {
@@ -50,6 +56,19 @@ export default function FinchHeader({
{title}
+ {pageTitle && (
+ <>
+
+
+ {pageTitle}
+
+ >
+ )}
{rightSlot}
diff --git a/src/components/FinchAppLayout/FinchMainContent.tsx b/src/components/FinchAppLayout/FinchMainContent.tsx
new file mode 100644
index 00000000..8f07bafd
--- /dev/null
+++ b/src/components/FinchAppLayout/FinchMainContent.tsx
@@ -0,0 +1,77 @@
+import { useRoutes } from 'react-router';
+import FinchPageTabs from './FinchPageTabs';
+import { useActiveRoute } from './hooks/useActiveRoute';
+import { buildPageRoutes } from './utils/pageRoutes';
+import { cn } from '@/lib/utils';
+
+import { RouteItem, RouteTab } from '@/types/navigationRouterTypes';
+
+export type FinchMainContentProps = {
+ /** Route definitions used to render the matched page component via React Router. */
+ routes: RouteItem[];
+ /** Additional CSS classes applied to the main outer element. */
+ className?: string;
+ /** Additional CSS classes applied to the scrolling element that holds the page padding. */
+ classNameScrollContainer?: string;
+ /** Additional CSS classes applied to the inner element directly rendering the route element. */
+ classNameInnerContainer?: string;
+ /** Additional CSS classes applied to the page tab strip. */
+ classNamePageTabs?: string;
+ /** Additional CSS classes applied to the active page tab. */
+ classNamePageTabsActive?: string;
+ /** Additional CSS classes applied to inactive page tabs. */
+ classNamePageTabsInactive?: string;
+};
+export default function FinchMainContent({
+ routes,
+ className,
+ classNameScrollContainer,
+ classNameInnerContainer,
+ classNamePageTabs,
+ classNamePageTabsActive,
+ classNamePageTabsInactive,
+ ...props
+}: FinchMainContentProps) {
+ const activeRoute = useActiveRoute(routes);
+
+ const page = (route: RouteItem, tab?: RouteTab) => {
+ const item = tab ?? route;
+ const isBackgroundTransparent =
+ tab?.isBackgroundTransparent ?? route.isBackgroundTransparent;
+ return (
+
+ {item.element}
+
+ );
+ };
+
+ const pages = useRoutes(buildPageRoutes(routes, page));
+
+ return (
+
+ {activeRoute?.tabs?.length ? (
+
+ ) : null}
+
+ {pages}
+
+
+ );
+}
diff --git a/src/components/FinchAppLayout/FinchPageTabs.tsx b/src/components/FinchAppLayout/FinchPageTabs.tsx
new file mode 100644
index 00000000..d4345c0a
--- /dev/null
+++ b/src/components/FinchAppLayout/FinchPageTabs.tsx
@@ -0,0 +1,59 @@
+import { NavLink } from 'react-router';
+import { toTabPath } from './utils/pageRoutes';
+import { cn } from '@/lib/utils';
+
+import { RouteTab } from '@/types/navigationRouterTypes';
+
+export type FinchPageTabsProps = {
+ /** Path of the route these tabs belong to, used to build each tab link. */
+ basePath: string;
+ /** Tab definitions rendered as links, in order. */
+ tabs: Pick[];
+ /** Additional CSS classes applied to the root nav element. */
+ className?: string;
+ /** Additional CSS classes applied to the active tab link. */
+ classNameActiveTab?: string;
+ /** Additional CSS classes applied to inactive tab links. */
+ classNameInactiveTab?: string;
+};
+
+const tabStyles = 'self-end px-4 py-3 text-sm font-medium border-b-2 transition-colors';
+
+/** Strip of tab links rendered above a page whose route declares `tabs`. */
+export default function FinchPageTabs({
+ basePath,
+ tabs,
+ className,
+ classNameActiveTab,
+ classNameInactiveTab,
+ ...props
+}: FinchPageTabsProps) {
+ return (
+
+ );
+}
diff --git a/src/components/FinchAppLayout/FinchSidebar.tsx b/src/components/FinchAppLayout/FinchSidebar.tsx
new file mode 100644
index 00000000..a7db94a6
--- /dev/null
+++ b/src/components/FinchAppLayout/FinchSidebar.tsx
@@ -0,0 +1,57 @@
+import { Link } from 'react-router';
+import { useActiveRoute } from './hooks/useActiveRoute';
+import { toRoutePath } from './utils/pageRoutes';
+import { cn } from '@/lib/utils';
+
+import { RouteItem } from '@/types/navigationRouterTypes';
+
+export type FinchSidebarProps = {
+ /** Route definitions used to render the sidebar navigation links. */
+ routes: RouteItem[];
+ /** Additional CSS classes applied to the root aside element. */
+ className?: string;
+ /** Additional CSS classes applied to the active navigation link. */
+ classNameActiveLink?: string;
+ /** Additional CSS classes applied to inactive navigation links. */
+ classNameInactiveLink?: string;
+};
+export default function FinchSidebar({
+ routes,
+ className,
+ classNameActiveLink,
+ classNameInactiveLink,
+ ...props
+}: FinchSidebarProps) {
+ const activeRoute = useActiveRoute(routes);
+ const navStyles = cn(
+ 'flex flex-col items-center justify-center h-20 aspect-square rounded-lg text-white hover:bg-sky-800 cursor-pointer',
+ classNameInactiveLink,
+ );
+ return (
+
+ );
+}
diff --git a/src/components/FinchAppLayout/hooks/useActiveRoute.ts b/src/components/FinchAppLayout/hooks/useActiveRoute.ts
new file mode 100644
index 00000000..619cebc0
--- /dev/null
+++ b/src/components/FinchAppLayout/hooks/useActiveRoute.ts
@@ -0,0 +1,18 @@
+import { useMemo } from 'react';
+import { matchRoutes, useLocation } from 'react-router';
+
+import { buildPageRoutes } from '../utils/pageRoutes';
+import { RouteItem } from '@/types/navigationRouterTypes';
+
+/**
+ * Returns the route matching the current location, if any.
+ *
+ * Ranking runs against the same config `FinchMainContent` renders, so the answer
+ * always agrees with the page on screen. The config is rebuilt only when `routes`
+ * changes, since three components call this hook on every navigation.
+ */
+export function useActiveRoute(routes: RouteItem[]) {
+ const location = useLocation();
+ const pageRoutes = useMemo(() => buildPageRoutes(routes), [routes]);
+ return matchRoutes(pageRoutes, location)?.[0]?.route.handle as RouteItem | undefined;
+}
diff --git a/src/components/FinchAppLayout/utils/pageRoutes.tsx b/src/components/FinchAppLayout/utils/pageRoutes.tsx
new file mode 100644
index 00000000..63a0fd39
--- /dev/null
+++ b/src/components/FinchAppLayout/utils/pageRoutes.tsx
@@ -0,0 +1,169 @@
+import { Navigate } from 'react-router';
+
+import type { RouteObject } from 'react-router';
+import { RouteItem, RouteTab } from '@/types/navigationRouterTypes';
+
+/** Renders the page body for a route, or for one of its tabs. */
+type PageRenderer = (route: RouteItem, tab?: RouteTab) => React.ReactNode;
+
+function trimSlashes(path: string) {
+ return path.replace(/\/+/g, '/').replace(/^\/|\/$/g, '');
+}
+
+/**
+ * Normalizes a route path to one leading slash and no trailing one.
+ *
+ * `"data"`, `"/data"` and `"/data/"` all become `"/data"`, and repeated slashes
+ * collapse, so a route behaves the same however its path was written. The root path
+ * stays `"/"`.
+ */
+export function toRoutePath(path: string) {
+ return `/${trimSlashes(path)}`;
+}
+
+/**
+ * Prefix that the root route's tabs sit behind.
+ *
+ * The root has no segment of its own, so its tabs hide behind a reserved `-` rather
+ * than taking top-level urls, where they would shadow other routes.
+ */
+function toTabPrefix(routePath: string) {
+ return routePath === '/' ? '-/' : '';
+}
+
+/** The path a tab registers beneath its route. */
+function toTabChildPath(tab: Pick, routePath: string) {
+ const segment = trimSlashes(tab.path);
+ if (!segment) {
+ throw new Error(
+ `Tab "${tab.label}" needs a path segment beneath "${routePath}", such as "overview".`,
+ );
+ }
+ return `${toTabPrefix(routePath)}${segment}`;
+}
+
+/** The catch-all a tabbed route registers for urls none of its tabs claim. */
+function toTabFallbackPath(routePath: string) {
+ return `${toTabPrefix(routePath)}*`;
+}
+
+/** Joins a path a route registers beneath itself onto the route's own path. */
+function toChildUrl(routePath: string, childPath: string) {
+ return toRoutePath(`${routePath}/${childPath}`);
+}
+
+/**
+ * Builds the url of a tab beneath its route, ignoring stray slashes on either path.
+ *
+ * The tab link, the redirect and the registered route all join the same
+ * `toTabChildPath`, so they cannot point at different urls.
+ */
+export function toTabPath({
+ basePath,
+ tab,
+}: {
+ basePath: string;
+ tab: Pick;
+}) {
+ const routePath = toRoutePath(basePath);
+ return toChildUrl(routePath, toTabChildPath(tab, routePath));
+}
+
+/** A url a route config occupies, and how to name it in an error. */
+type Page = { url: string; name: string };
+
+/**
+ * The first page that lands on a url an earlier page already took.
+ *
+ * Keys ignore case because React Router does: `/data` and `/Data` are one url, and only
+ * the page declared first would ever render.
+ */
+function findCollision(pages: Page[]) {
+ const seen = new Map();
+ for (const page of pages) {
+ const key = page.url.toLowerCase();
+ const earlier = seen.get(key);
+ if (earlier) {
+ return { earlier, later: page };
+ }
+ seen.set(key, page);
+ }
+}
+
+/**
+ * Every url a route occupies: its own path, one per tab, and the tab fallback.
+ *
+ * Routes and tabs share one url space, so they are collected together and checked
+ * against each other.
+ */
+function toPages(route: RouteItem): Page[] {
+ const path = toRoutePath(route.path);
+ const page = { url: path, name: `the route "${route.label}"` };
+ if (!route.tabs?.length) {
+ return [page];
+ }
+ return [
+ page,
+ ...route.tabs.map((tab) => ({
+ url: toTabPath({ basePath: path, tab }),
+ name: `the "${tab.label}" tab of "${path}"`,
+ })),
+ {
+ url: toChildUrl(path, toTabFallbackPath(path)),
+ name: `the fallback of "${path}"`,
+ },
+ ];
+}
+
+/**
+ * Builds the React Router config for a set of Finch routes.
+ *
+ * A route with `tabs` becomes a parent route whose children are the tabs, plus an
+ * index and a catch-all that both redirect to the first tab. `useRoutes` renders
+ * this config and `matchRoutes` ranks against it, so the tab strip and the page on
+ * screen can never disagree about which route is active.
+ *
+ * Throws when a tab path holds no segment, and when two pages resolve to the same
+ * url. Urls that differ only by case are the same url, since routes match without case.
+ *
+ * Omit `renderPage` to build the config for matching only.
+ */
+export function buildPageRoutes(routes: RouteItem[], renderPage?: PageRenderer): RouteObject[] {
+ const collision = findCollision(routes.flatMap(toPages));
+ if (collision) {
+ const { earlier, later } = collision;
+ const caseNote =
+ earlier.url === later.url
+ ? ''
+ : ` Urls ignore case, so "${later.url}" is the same url.`;
+ throw new Error(
+ `Two pages both resolve to "${earlier.url}": ${earlier.name} and ${later.name}.` +
+ `${caseNote} Give each its own path.`,
+ );
+ }
+
+ return routes.map((route) => {
+ const path = toRoutePath(route.path);
+ const tabs = route.tabs;
+ if (!tabs?.length) {
+ return { path, handle: route, element: renderPage?.(route) };
+ }
+
+ const redirectToFirstTab = (
+
+ );
+
+ return {
+ path,
+ handle: route,
+ children: [
+ { index: true, element: redirectToFirstTab },
+ ...tabs.map((tab) => ({
+ path: toTabChildPath(tab, path),
+ element: renderPage?.(route, tab),
+ })),
+ { path: toTabFallbackPath(path), element: redirectToFirstTab },
+ ],
+ };
+ });
+}
diff --git a/src/components/FinchMainContent.tsx b/src/components/FinchMainContent.tsx
deleted file mode 100644
index 7d431105..00000000
--- a/src/components/FinchMainContent.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { Routes, Route } from 'react-router';
-import { cn } from '@/lib/utils';
-
-import { RouteItem } from '@/types/navigationRouterTypes';
-
-export type FinchMainContentProps = {
- /** Route definitions used to render the matched page component via React Router. */
- routes: RouteItem[];
- /** Additional CSS classes applied to the main outer element. */
- className?: string;
- /** Additional CSS classes applied to the inner element directly rendering the route element. */
- classNameInnerContainer?: string;
-};
-export default function FinchMainContent({
- routes,
- className,
- classNameInnerContainer,
- ...props
-}: FinchMainContentProps) {
- return (
-
-
- {routes.map((route) => (
-
- {route.element}
-
- }
- />
- ))}
-
-
- );
-}
diff --git a/src/components/FinchSidebar.tsx b/src/components/FinchSidebar.tsx
deleted file mode 100644
index e2c3fed9..00000000
--- a/src/components/FinchSidebar.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { NavLink } from 'react-router';
-import { cn } from '@/lib/utils';
-
-import { RouteItem } from '@/types/navigationRouterTypes';
-
-export type FinchSidebarProps = {
- /** Route definitions used to render the sidebar navigation links. */
- routes: RouteItem[];
- /** Additional CSS classes applied to the root aside element. */
- className?: string;
- /** Additional CSS classes applied to the active navigation link. */
- classNameActiveLink?: string;
- /** Additional CSS classes applied to inactive navigation links. */
- classNameInactiveLink?: string;
-};
-export default function FinchSidebar({
- routes,
- className,
- classNameActiveLink,
- classNameInactiveLink,
- ...props
-}: FinchSidebarProps) {
- const navStyles = cn(
- 'flex flex-col items-center justify-center h-20 aspect-square rounded-lg text-white hover:bg-sky-800 cursor-pointer',
- classNameInactiveLink,
- );
- return (
-
- );
-}
diff --git a/src/index.ts b/src/index.ts
index 85224410..8e87723f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -28,30 +28,33 @@ export type { TiledHeatmapSelectorProps } from './features/TiledHeatmapSelector'
export { default as Beamstop } from './features/Beamstop';
export type { BeamstopProps } from './features/Beamstop';
-export { default as FinchAppLayout } from './components/FinchAppLayout';
-export type { FinchAppLayoutProps } from './components/FinchAppLayout';
+export { default as FinchAppLayout } from './components/FinchAppLayout/FinchAppLayout';
+export type { FinchAppLayoutProps } from './components/FinchAppLayout/FinchAppLayout';
-export { default as FinchHeader } from './components/FinchHeader';
-export type { FinchHeaderProps } from './components/FinchHeader';
+export { default as FinchHeader } from './components/FinchAppLayout/FinchHeader';
+export type { FinchHeaderProps } from './components/FinchAppLayout/FinchHeader';
-export { default as FinchMainContent } from './components/FinchMainContent';
-export type { FinchMainContentProps } from './components/FinchMainContent';
+export { default as FinchMainContent } from './components/FinchAppLayout/FinchMainContent';
+export type { FinchMainContentProps } from './components/FinchAppLayout/FinchMainContent';
-export { default as FinchSidebar } from './components/FinchSidebar';
-export type { FinchSidebarProps } from './components/FinchSidebar';
+export { default as FinchPageTabs } from './components/FinchAppLayout/FinchPageTabs';
+export type { FinchPageTabsProps } from './components/FinchAppLayout/FinchPageTabs';
+
+export { default as FinchSidebar } from './components/FinchAppLayout/FinchSidebar';
+export type { FinchSidebarProps } from './components/FinchAppLayout/FinchSidebar';
// @deprecated — use Finch* equivalents
-export { default as HubAppLayout } from './components/FinchAppLayout';
-export type { FinchAppLayoutProps as HubAppLayoutProps } from './components/FinchAppLayout';
+export { default as HubAppLayout } from './components/FinchAppLayout/FinchAppLayout';
+export type { FinchAppLayoutProps as HubAppLayoutProps } from './components/FinchAppLayout/FinchAppLayout';
-export { default as HubHeader } from './components/FinchHeader';
-export type { FinchHeaderProps as HubHeaderProps } from './components/FinchHeader';
+export { default as HubHeader } from './components/FinchAppLayout/FinchHeader';
+export type { FinchHeaderProps as HubHeaderProps } from './components/FinchAppLayout/FinchHeader';
-export { default as HubMainContent } from './components/FinchMainContent';
-export type { FinchMainContentProps as HubMainContentProps } from './components/FinchMainContent';
+export { default as HubMainContent } from './components/FinchAppLayout/FinchMainContent';
+export type { FinchMainContentProps as HubMainContentProps } from './components/FinchAppLayout/FinchMainContent';
-export { default as HubSidebar } from './components/FinchSidebar';
-export type { FinchSidebarProps as HubSidebarProps } from './components/FinchSidebar';
+export { default as HubSidebar } from './components/FinchAppLayout/FinchSidebar';
+export type { FinchSidebarProps as HubSidebarProps } from './components/FinchAppLayout/FinchSidebar';
export { default as ContainerQServer } from './components/QServer/ContainerQServer';
export type { ContainerQServerProps } from './components/QServer/ContainerQServer';
@@ -305,7 +308,7 @@ export type {
} from './api/qServer/types';
//TYPES
-export type { RouteItem } from './types/navigationRouterTypes';
+export type { RouteItem, RouteTab } from './types/navigationRouterTypes';
export type { Device, Devices } from './types/deviceControllerTypes';
//CONTEXT PROVIDERS
diff --git a/src/stories/FinchAppLayout.stories.tsx b/src/stories/FinchAppLayout.stories.tsx
index 08c0fb94..af1c0a5c 100644
--- a/src/stories/FinchAppLayout.stories.tsx
+++ b/src/stories/FinchAppLayout.stories.tsx
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react';
-import FinchAppLayout from '@/components/FinchAppLayout';
+import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout';
import Paper from '@/components/Paper';
-import { House, Joystick, StackPlus, ImageSquare } from '@phosphor-icons/react';
+import { House, Joystick, StackPlus, ImageSquare, ChartScatter } from '@phosphor-icons/react';
import { RouteItem } from '@/types/navigationRouterTypes';
import { MemoryRouter } from 'react-router';
@@ -13,10 +13,22 @@ import { MemoryRouter } from 'react-router';
* Each RouteItem defines a navigation tab/page with the following structure:
*
* RouteItem {
- * element: React.ReactNode - The component/content to render when this route is active
- * path: string - The URL path for this route (e.g., "/", "/control", "/data")
- * label: string - The display text shown in the sidebar navigation tab
- * icon: React.ReactNode - The icon displayed next to the label in the sidebar
+ * path: string - The URL path for this route (e.g., "/", "/control"), static only
+ * label: string - The display text shown in the sidebar navigation tab
+ * element?: React.ReactNode - The component/content to render when this route is active
+ * tabs?: RouteTab[] - Tabs shown in a strip above the page, each with its own URL
+ * icon?: React.ReactNode - The icon displayed next to the label in the sidebar
+ * isBackgroundTransparent?: boolean - Renders the page transparent with white text (default: false)
+ * classNameContainer?: string - Additional CSS classes for the page container
+ * showPageTitle?: boolean - Shows this route's label in the header (default: true)
+ * }
+ *
+ * A route declares either `element` or `tabs`, never both.
+ *
+ * RouteTab {
+ * path: string - Path segment under the route path (e.g. "live" under "/explorer")
+ * label: string - The display text shown on the tab
+ * element: React.ReactNode - The component/content to render when this tab is active
* }
*
* Example:
@@ -32,6 +44,15 @@ import { MemoryRouter } from 'react-router';
* path: "/control",
* label: "Control",
* icon:
+ * },
+ * {
+ * path: "/explorer",
+ * label: "Explorer",
+ * icon: ,
+ * tabs: [
+ * { element: , path: "live", label: "Live" },
+ * { element: , path: "replay", label: "Replay" }
+ * ]
* }
* ];
*
@@ -39,6 +60,8 @@ import { MemoryRouter } from 'react-router';
* 1. Create sidebar navigation tabs based on the label and icon
* 2. Handle routing between different paths
* 3. Render the corresponding element when a route is selected
+ * 4. Draw a tab strip above the page for any route that declares tabs
+ * 5. Show the active route label in the header, after the app title
*/
const Page1 = () => {
@@ -85,6 +108,34 @@ const routes: RouteItem[] = [
{ element: , path: '/data', label: 'Data', icon: },
];
+type ExplorerTabProps = {
+ name: string;
+};
+
+const ExplorerTab = ({ name }: ExplorerTabProps) => {
+ return (
+
+
{name}
+
Each tab has its own URL under /explorer.
+
+ );
+};
+
+const routesWithTabs: RouteItem[] = [
+ ...routes,
+ {
+ path: '/explorer',
+ label: 'Explorer',
+ icon: ,
+ tabs: [
+ { element: , path: 'live', label: 'Live' },
+ { element: , path: 'explore', label: 'Explore' },
+ { element: , path: 'replay', label: 'Replay' },
+ { element: , path: 'run', label: 'Run' },
+ ],
+ },
+];
+
const meta = {
title: 'Layout Components/FinchAppLayout',
component: FinchAppLayout,
@@ -101,15 +152,53 @@ Each RouteItem defines a navigation tab/page with the following structure:
\`\`\`typescript
RouteItem {
- element: React.ReactNode // The component/content to render when this route is active
- path: string // The URL path for this route (e.g., "/", "/control", "/data")
+ element?: React.ReactNode // The component/content to render when this route is active
+ path: string // The URL path, static only (e.g., "/", "/control", "/data")
label: string // The display text shown in the sidebar navigation tab
icon: React.ReactNode // The icon displayed next to the label in the sidebar
+ tabs?: RouteTab[] // Tabs shown in a strip above the page (omit \`element\` when set)
isBackgroundTransparent?: boolean // If true, page background is transparent (default: false)
classNameContainer?: string // Additional CSS classes applied to the page container
+ showPageTitle?: boolean // Whether the header shows this route's label (default: true)
+}
+\`\`\`
+
+A route declares either \`element\` or \`tabs\`, never both.
+
+Route paths must be static. The sidebar links to each \`path\` directly, so a dynamic
+segment such as \`/runs/:uid\` would render a link to that literal text.
+
+### Page Tabs
+
+A route can declare \`tabs\` instead of an \`element\`. Finch then draws a tab strip
+above the page and gives each tab its own URL beneath the route path, so tabs are
+deep-linkable and survive a refresh. Visiting the bare route path redirects to the
+first tab.
+
+\`\`\`typescript
+RouteTab {
+ element: React.ReactNode // The component/content to render when this tab is active
+ path: string // Path segment under the route path (e.g. "live" under "/explorer")
+ label: string // The display text shown on the tab
+ isBackgroundTransparent?: boolean // If true, this tab's background is transparent
+ classNameContainer?: string // Additional CSS classes applied to this tab's container
}
\`\`\`
+\`\`\`typescript
+const routes: RouteItem[] = [
+ {
+ path: '/explorer',
+ label: 'Explorer',
+ icon: ,
+ tabs: [
+ { element: , path: 'live', label: 'Live' }, // -> /explorer/live
+ { element: , path: 'replay', label: 'Replay' }, // -> /explorer/replay
+ ],
+ },
+];
+\`\`\`
+
### Basic Example:
\`\`\`typescript
const routes: RouteItem[] = [
@@ -130,7 +219,7 @@ const routes: RouteItem[] = [
### Full App Example (from App.tsx):
\`\`\`typescript
-import FinchAppLayout from '@/components/FinchAppLayout';
+import FinchAppLayout from '@/components/FinchAppLayout/FinchAppLayout';
import { RouteItem } from '@/types/navigationRouterTypes';
import { House, Table, TestTube, Question } from '@phosphor-icons/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -189,6 +278,8 @@ function App() {
2. **Handles routing** between different paths using React Router
3. **Renders the corresponding element** when a route is selected
4. **Applies per-route styling** via \`isBackgroundTransparent\` or \`classNameContainer\`
+5. **Draws a tab strip** above the page for any route that declares \`tabs\`
+6. **Shows the active route label** in the header, after the app title (set \`showPageTitle\` on the layout to turn it off everywhere, or on a route to override that)
The component uses React Router internally to manage navigation between different pages/views.
`,
@@ -196,8 +287,8 @@ The component uses React Router internally to manage navigation between differen
},
},
decorators: [
- (Story, { args }) => (
-
+ (Story, { args, parameters }) => (
+
),
@@ -223,6 +314,25 @@ export const CustomTitle: Story = {
},
};
+export const WithPageTabs: Story = {
+ parameters: { initialPath: '/explorer' },
+ args: {
+ routes: routesWithTabs,
+ headerTitle: 'Finch Dev Mode',
+ className: 'w-full h-full',
+ },
+};
+
+export const WithoutPageTitle: Story = {
+ parameters: { initialPath: '/control' },
+ args: {
+ routes: routes,
+ headerTitle: 'Finch Dev Mode',
+ showPageTitle: false,
+ className: 'w-full h-full',
+ },
+};
+
export const CustomClasses: Story = {
args: {
routes: routes,
@@ -232,6 +342,7 @@ export const CustomClasses: Story = {
classNameSidebarActiveLink: 'bg-red-500',
classNameSidebarInactiveLink: 'text-red-500 hover:bg-purple-300 hover:text-slate-900',
classNameMainContent: 'bg-red-300',
+ classNameMainContentScrollContainer: 'p-2',
classNameHeader: 'bg-red-200',
classNameHeaderTitle: 'text-red-900',
className: 'w-full h-full',
diff --git a/src/testing/tests/FinchAppLayout.test.tsx b/src/testing/tests/FinchAppLayout.test.tsx
index fe77f536..d32227b5 100644
--- a/src/testing/tests/FinchAppLayout.test.tsx
+++ b/src/testing/tests/FinchAppLayout.test.tsx
@@ -1,7 +1,9 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { MemoryRouter } from 'react-router';
-import FinchAppLayout from '../../components/FinchAppLayout';
+import FinchAppLayout, {
+ FinchAppLayoutProps,
+} from '../../components/FinchAppLayout/FinchAppLayout';
import { RouteItem } from '../../types/navigationRouterTypes';
const mockRoutes: RouteItem[] = [
@@ -9,10 +11,12 @@ const mockRoutes: RouteItem[] = [
{ path: '/settings', label: 'Settings', element:
Settings Page
},
];
-function renderLayout(props = {}) {
+type RenderLayoutOptions = Partial & { path?: string };
+
+function renderLayout({ routes = mockRoutes, path = '/home', ...props }: RenderLayoutOptions = {}) {
return render(
-
-
+
+ ,
);
}
@@ -24,9 +28,10 @@ describe('FinchAppLayout Component', () => {
});
it('renders sidebar navigation links for each route', () => {
- renderLayout();
- expect(screen.getByText('Home')).toBeInTheDocument();
- expect(screen.getByText('Settings')).toBeInTheDocument();
+ const { container } = renderLayout();
+ const sidebar = within(container.querySelector('aside') as HTMLElement);
+ expect(sidebar.getByText('Home')).toBeInTheDocument();
+ expect(sidebar.getByText('Settings')).toBeInTheDocument();
});
it('renders the default header title', () => {
@@ -76,19 +81,86 @@ describe('FinchAppLayout Component', () => {
expect(container.firstChild).toHaveClass('my-root-class');
});
+ it('renders the active route label as the header page title', () => {
+ const { container } = renderLayout();
+ expect(container.querySelector('header')).toHaveTextContent('Home');
+ });
+
+ it('renders no header page title for a route that opts out', () => {
+ const optedOut: RouteItem[] = [
+ { path: '/home', label: 'Home', element: , showPageTitle: false },
+ ];
+ const { container } = renderLayout({ routes: optedOut });
+ expect(container.querySelector('header')).not.toHaveTextContent('Home');
+ });
+
+ it('renders the header page title at the root route like any other route', () => {
+ const rootRoutes: RouteItem[] = [{ path: '/', label: 'Overview', element: }];
+ const { container } = renderLayout({ routes: rootRoutes, path: '/' });
+ expect(container.querySelector('header')).toHaveTextContent('Overview');
+ });
+
+ it('renders no header page title when the layout opts out', () => {
+ const { container } = renderLayout({ showPageTitle: false });
+ expect(container.querySelector('header')).not.toHaveTextContent('Home');
+ });
+
+ it('renders the header page title for a route that opts in while the layout opts out', () => {
+ const optedIn: RouteItem[] = [
+ { path: '/home', label: 'Home', element: , showPageTitle: true },
+ ];
+ const { container } = renderLayout({ routes: optedIn, showPageTitle: false });
+ expect(container.querySelector('header')).toHaveTextContent('Home');
+ });
+
+ it('applies classNameHeaderPageTitle to the header page title', () => {
+ renderLayout({ classNameHeaderPageTitle: 'text-red-500' });
+ expect(screen.getByText('Home', { selector: 'header span' })).toHaveClass('text-red-500');
+ });
+
+ it('keeps a tabbed route active when a dynamic sibling could also match', () => {
+ const withDynamicSibling: RouteItem[] = [
+ {
+ path: '/data',
+ label: 'Data',
+ tabs: [{ path: 'details', label: 'Details', element: