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
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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`,
Expand Down
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <ChartLineIcon size={32} />,
tabs: [
{ path: 'live', label: 'Live', element: <LiveTab /> }, // -> /explorer/live
{ path: 'replay', label: 'Replay', element: <ReplayTab /> }, // -> /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: <LiveTab /> }, // -> /-/live
{ path: 'replay', label: 'Replay', element: <ReplayTab /> }, // -> /-/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: <LiveTab /> },
{ path: '/live', label: 'Replay', element: <ReplayTab /> }, // 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 `<LiveTab />` would ever render.


## Alternative Installation - Clone This Repo
Expand Down
2 changes: 1 addition & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
/**
Expand All @@ -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. */
Expand All @@ -39,19 +55,29 @@ export type FinchAppLayoutProps = {
export default function FinchAppLayout({
routes,
headerTitle,
showPageTitle,
headerLogoUrl,
headerLogoIcon,
classNameMainContent,
classNameMainContentScrollContainer,
classNameMainContentInnerContainer,
classNamePageTabs,
classNamePageTabsActive,
classNamePageTabsInactive,
classNameHeader,
classNameHeaderTitle,
classNameHeaderPageTitle,
classNameSidebar,
classNameSidebarActiveLink,
classNameSidebarInactiveLink,
classNameImage,
className,
...props
}: FinchAppLayoutProps) {
const activeRoute = useActiveRoute(routes);
const isPageTitleShown = activeRoute?.showPageTitle ?? showPageTitle ?? true;
const pageTitle = isPageTitleShown ? activeRoute?.label : undefined;

return (
<div
className={cn(
Expand All @@ -68,16 +94,22 @@ export default function FinchAppLayout({
/>
<FinchHeader
title={headerTitle}
pageTitle={pageTitle}
logoUrl={headerLogoUrl}
logoIcon={headerLogoIcon}
className={classNameHeader}
classNameTitle={classNameHeaderTitle}
classNamePageTitle={classNameHeaderPageTitle}
classNameImage={classNameImage}
/>
<FinchMainContent
routes={routes}
className={cn('h-[calc(100vh-4rem)]', classNameMainContent)}
classNameScrollContainer={classNameMainContentScrollContainer}
classNameInnerContainer={classNameMainContentInnerContainer}
classNamePageTabs={classNamePageTabs}
classNamePageTabsActive={classNamePageTabsActive}
classNamePageTabsInactive={classNamePageTabsInactive}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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;
};
Expand All @@ -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) {
Expand All @@ -50,6 +56,19 @@ export default function FinchHeader({
<h1 className={cn('text-sky-950 text-2xl font-semibold', classNameTitle)}>
{title}
</h1>
{pageTitle && (
Comment thread
CammilleCC marked this conversation as resolved.
<>
<span className="w-px h-6 bg-sky-950/20" />
<span
className={cn(
'text-xl font-medium text-sky-950/70',
classNamePageTitle,
)}
>
{pageTitle}
</span>
</>
)}
</div>
{rightSlot}
</header>
Expand Down
77 changes: 77 additions & 0 deletions src/components/FinchAppLayout/FinchMainContent.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section
className={cn(
isBackgroundTransparent ? 'bg-transparent text-white' : 'bg-white',
'w-full h-full rounded-md',
classNameInnerContainer,
route.classNameContainer,
tab?.classNameContainer,
)}
>
{item.element}
</section>
);
};

const pages = useRoutes(buildPageRoutes(routes, page));

return (
<main
className={cn('bg-sky-900 h-full w-full flex flex-col overflow-hidden', className)}
{...props}
>
{activeRoute?.tabs?.length ? (
<FinchPageTabs
basePath={activeRoute.path}
tabs={activeRoute.tabs}
className={classNamePageTabs}
classNameActiveTab={classNamePageTabsActive}
classNameInactiveTab={classNamePageTabsInactive}
/>
) : null}
<div className={cn('flex-1 min-h-0 overflow-y-auto p-8', classNameScrollContainer)}>
{pages}
</div>
</main>
);
}
59 changes: 59 additions & 0 deletions src/components/FinchAppLayout/FinchPageTabs.tsx
Original file line number Diff line number Diff line change
@@ -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<RouteTab, 'path' | 'label'>[];
/** 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 (
<nav
className={cn(
'flex gap-1 px-7 h-12 items-stretch shrink-0 bg-black/[0.14] border-b border-white/10',
className,
)}
{...props}
>
{tabs.map((tab) => (
<NavLink
key={tab.path}
to={toTabPath({ basePath, tab })}
end
className={({ isActive }) =>
cn(
tabStyles,
isActive
? 'text-white border-sky-300'
: 'text-white/60 border-transparent hover:text-white/80',
isActive ? classNameActiveTab : classNameInactiveTab,
)
}
>
{tab.label}
</NavLink>
))}
</nav>
);
}
Loading
Loading