From 79acc3e0fd7adeae2f804634a9d63631e6c78199 Mon Sep 17 00:00:00 2001 From: Cammille Carinan Date: Wed, 29 Jul 2026 03:14:59 +0200 Subject: [PATCH 1/9] feat(layout): add page tabs to routes --- .github/workflows/test.yml | 4 +- src/components/FinchAppLayout.tsx | 16 ++ src/components/FinchMainContent.tsx | 77 +++++--- src/components/FinchPageTabs.tsx | 58 ++++++ src/hooks/useActiveRoute.ts | 16 ++ src/index.ts | 5 +- src/lib/pageRoutes.tsx | 37 ++++ src/testing/tests/FinchMainContent.test.tsx | 196 +++++++++++++++++++- src/testing/tests/FinchPageTabs.test.tsx | 80 ++++++++ src/testing/tests/useActiveRoute.test.tsx | 44 +++++ src/types/navigationRouterTypes.ts | 40 +++- 11 files changed, 536 insertions(+), 37 deletions(-) create mode 100644 src/components/FinchPageTabs.tsx create mode 100644 src/hooks/useActiveRoute.ts create mode 100644 src/lib/pageRoutes.tsx create mode 100644 src/testing/tests/FinchPageTabs.test.tsx create mode 100644 src/testing/tests/useActiveRoute.test.tsx 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/src/components/FinchAppLayout.tsx b/src/components/FinchAppLayout.tsx index 147fa9b9..36eecf3a 100644 --- a/src/components/FinchAppLayout.tsx +++ b/src/components/FinchAppLayout.tsx @@ -21,8 +21,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. */ @@ -42,7 +50,11 @@ export default function FinchAppLayout({ headerLogoUrl, headerLogoIcon, classNameMainContent, + classNameMainContentScrollContainer, classNameMainContentInnerContainer, + classNamePageTabs, + classNamePageTabsActive, + classNamePageTabsInactive, classNameHeader, classNameHeaderTitle, classNameSidebar, @@ -77,7 +89,11 @@ export default function FinchAppLayout({ ); diff --git a/src/components/FinchMainContent.tsx b/src/components/FinchMainContent.tsx index 7d431105..7fe1f62c 100644 --- a/src/components/FinchMainContent.tsx +++ b/src/components/FinchMainContent.tsx @@ -1,42 +1,77 @@ -import { Routes, Route } from 'react-router'; +import { useRoutes } from 'react-router'; +import FinchPageTabs from '@/components/FinchPageTabs'; +import { useActiveRoute } from '@/hooks/useActiveRoute'; +import { buildPageRoutes } from '@/lib/pageRoutes'; import { cn } from '@/lib/utils'; -import { RouteItem } from '@/types/navigationRouterTypes'; +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 ( -
- - {routes.map((route) => ( - - {route.element} - - } - /> - ))} - +
+ {activeRoute?.tabs?.length ? ( + + ) : null} +
+ {pages} +
); } diff --git a/src/components/FinchPageTabs.tsx b/src/components/FinchPageTabs.tsx new file mode 100644 index 00000000..e38dd824 --- /dev/null +++ b/src/components/FinchPageTabs.tsx @@ -0,0 +1,58 @@ +import { NavLink, resolvePath } from 'react-router'; +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-[18px] pt-[11px] pb-3 text-[15px] font-medium border-b-[3px] 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/hooks/useActiveRoute.ts b/src/hooks/useActiveRoute.ts new file mode 100644 index 00000000..dae07bd3 --- /dev/null +++ b/src/hooks/useActiveRoute.ts @@ -0,0 +1,16 @@ +import { matchRoutes, useLocation } from 'react-router'; + +import { buildPageRoutes } from '@/lib/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. + */ +export function useActiveRoute(routes: RouteItem[]) { + const location = useLocation(); + const matches = matchRoutes(buildPageRoutes(routes), location); + return matches?.[0]?.route.handle as RouteItem | undefined; +} diff --git a/src/index.ts b/src/index.ts index 85224410..17df1328 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,9 @@ export type { FinchHeaderProps } from './components/FinchHeader'; export { default as FinchMainContent } from './components/FinchMainContent'; export type { FinchMainContentProps } from './components/FinchMainContent'; +export { default as FinchPageTabs } from './components/FinchPageTabs'; +export type { FinchPageTabsProps } from './components/FinchPageTabs'; + export { default as FinchSidebar } from './components/FinchSidebar'; export type { FinchSidebarProps } from './components/FinchSidebar'; @@ -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/lib/pageRoutes.tsx b/src/lib/pageRoutes.tsx new file mode 100644 index 00000000..9c3b3ff9 --- /dev/null +++ b/src/lib/pageRoutes.tsx @@ -0,0 +1,37 @@ +import { Navigate, resolvePath } 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. */ +export type PageRenderer = (route: RouteItem, tab?: RouteTab) => React.ReactNode; + +/** + * 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. + * + * Omit `renderPage` to build the config for matching only. + */ +export function buildPageRoutes(routes: RouteItem[], renderPage?: PageRenderer): RouteObject[] { + return routes.map((route) => { + const tabs = route.tabs; + if (!tabs?.length) { + return { path: route.path, handle: route, element: renderPage?.(route) }; + } + + const redirectToFirstTab = ; + return { + path: route.path, + handle: route, + children: [ + { index: true, element: redirectToFirstTab }, + ...tabs.map((tab) => ({ path: tab.path, element: renderPage?.(route, tab) })), + { path: '*', element: redirectToFirstTab }, + ], + }; + }); +} diff --git a/src/testing/tests/FinchMainContent.test.tsx b/src/testing/tests/FinchMainContent.test.tsx index c9f09497..330f3774 100644 --- a/src/testing/tests/FinchMainContent.test.tsx +++ b/src/testing/tests/FinchMainContent.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchMainContent from '../../components/FinchMainContent'; +import FinchMainContent, { FinchMainContentProps } from '../../components/FinchMainContent'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ @@ -9,10 +9,28 @@ const mockRoutes: RouteItem[] = [ { path: '/settings', label: 'Settings', element:
Settings Page
}, ]; -function renderContent(initialPath = '/home', props = {}) { +const tabbedRoutes: RouteItem[] = [ + { path: '/home', label: 'Home', element:
Home Page
}, + { + path: '/explorer', + label: 'Explorer', + tabs: [ + { path: 'live', label: 'Live', element:
Live Tab
}, + { path: 'explore', label: 'Explore', element:
Explore Tab
}, + ], + }, +]; + +type RenderContentOptions = Partial & { path?: string }; + +function renderContent({ + routes = mockRoutes, + path = '/home', + ...props +}: RenderContentOptions = {}) { return render( - - + + , ); } @@ -24,28 +42,28 @@ describe('FinchMainContent Component', () => { }); it('renders the matched route element', () => { - renderContent('/home'); + renderContent({ path: '/home' }); expect(screen.getByText('Home Page')).toBeInTheDocument(); }); it('renders a different matched route element', () => { - renderContent('/settings'); + renderContent({ path: '/settings' }); expect(screen.getByText('Settings Page')).toBeInTheDocument(); }); it('does not render a non-active route element', () => { - renderContent('/home'); + renderContent({ path: '/home' }); expect(screen.queryByText('Settings Page')).not.toBeInTheDocument(); }); it('renders nothing for an unmatched path', () => { - renderContent('/unknown'); + renderContent({ path: '/unknown' }); expect(screen.queryByText('Home Page')).not.toBeInTheDocument(); expect(screen.queryByText('Settings Page')).not.toBeInTheDocument(); }); it('applies a custom className to the main element', () => { - const { container } = renderContent('/home', { className: 'my-main-class' }); + const { container } = renderContent({ className: 'my-main-class' }); expect(container.querySelector('main')).toHaveClass('my-main-class'); }); @@ -53,4 +71,164 @@ describe('FinchMainContent Component', () => { const { container } = renderContent(); expect(container.querySelector('main')).toBeInTheDocument(); }); + + it('lets classNameScrollContainer replace the default page padding', () => { + const { container } = renderContent({ classNameScrollContainer: 'p-0' }); + const scrollContainer = container.querySelector('main > div'); + expect(scrollContainer).toHaveClass('p-0'); + expect(scrollContainer).not.toHaveClass('p-8'); + }); + + it('renders a tab strip on a route that declares tabs', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/live' }); + expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.getByText('Explore')).toBeInTheDocument(); + }); + + it('renders no tab strip on a route without tabs', () => { + const { container } = renderContent({ routes: tabbedRoutes }); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('renders only the active tab element', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/live' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + expect(screen.queryByText('Explore Tab')).not.toBeInTheDocument(); + }); + + it('redirects the bare route path to its first tab', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + }); + + it('redirects an unknown subpath to the first tab', () => { + renderContent({ routes: tabbedRoutes, path: '/explorer/typo' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + }); + + it('applies the parent route transparent background to its tabs', () => { + const transparentRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + isBackgroundTransparent: true, + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + const { container } = renderContent({ + routes: transparentRoute, + path: '/explorer/live', + }); + expect(container.querySelector('section')).toHaveClass('bg-transparent'); + }); + + it('lets a tab override the parent route background setting', () => { + const mixedRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + isBackgroundTransparent: true, + tabs: [ + { + path: 'live', + label: 'Live', + element:
Live Tab
, + isBackgroundTransparent: false, + }, + ], + }, + ]; + const { container } = renderContent({ routes: mixedRoute, path: '/explorer/live' }); + expect(container.querySelector('section')).toHaveClass('bg-white'); + }); + + it('merges the parent route container classes into its tabs', () => { + const styledRoute: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + classNameContainer: 'bg-slate-50', + tabs: [ + { + path: 'live', + label: 'Live', + element:
Live Tab
, + classNameContainer: 'p-4', + }, + ], + }, + ]; + const { container } = renderContent({ routes: styledRoute, path: '/explorer/live' }); + expect(container.querySelector('section')).toHaveClass('bg-slate-50', 'p-4'); + }); + + it('renders nothing rather than crashing for a route with an empty tabs array', () => { + const emptyTabs: RouteItem[] = [{ path: '/explorer', label: 'Explorer', tabs: [] }]; + const { container } = renderContent({ routes: emptyTabs, path: '/explorer' }); + expect(container.querySelector('main')).toBeInTheDocument(); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('redirects a tabbed route at the root path to its first tab', () => { + const rootTabbed: RouteItem[] = [ + { + path: '/', + label: 'Home', + tabs: [ + { path: 'live', label: 'Live', element:
Live Tab
}, + { path: 'replay', label: 'Replay', element:
Replay Tab
}, + ], + }, + ]; + renderContent({ routes: rootTabbed, path: '/' }); + expect(screen.getByText('Live Tab')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Live' })).toHaveAttribute('href', '/live'); + }); + + it('renders a static route that outranks a tabbed route sharing its prefix', () => { + const overlapping: RouteItem[] = [ + { + path: '/data', + label: 'Data', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + { path: '/data/details', label: 'Details', element:
Details Page
}, + ]; + const { container } = renderContent({ routes: overlapping, path: '/data/details' }); + expect(screen.getByText('Details Page')).toBeInTheDocument(); + expect(container.querySelector('nav')).not.toBeInTheDocument(); + }); + + it('renders the tab strip when a root route precedes the tabbed route', () => { + const withRootRoute: RouteItem[] = [ + { path: '/', label: 'Home', element:
Root Page
}, + { + path: '/explorer', + label: 'Explorer', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + renderContent({ routes: withRootRoute, path: '/explorer/live' }); + expect(screen.getByText('Live')).toBeInTheDocument(); + }); + + it('applies classNamePageTabs to the tab strip', () => { + const { container } = renderContent({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabs: 'my-tabs-class', + }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); + }); + + it('applies classNamePageTabsActive and classNamePageTabsInactive to the right tabs', () => { + renderContent({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabsActive: 'active-tab-class', + classNamePageTabsInactive: 'inactive-tab-class', + }); + expect(screen.getByText('Live').closest('a')).toHaveClass('active-tab-class'); + expect(screen.getByText('Explore').closest('a')).toHaveClass('inactive-tab-class'); + }); }); diff --git a/src/testing/tests/FinchPageTabs.test.tsx b/src/testing/tests/FinchPageTabs.test.tsx new file mode 100644 index 00000000..790fd9e3 --- /dev/null +++ b/src/testing/tests/FinchPageTabs.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import FinchPageTabs from '../../components/FinchPageTabs'; +import { RouteTab } from '../../types/navigationRouterTypes'; + +const mockTabs: RouteTab[] = [ + { path: 'live', label: 'Live', element:
}, + { path: 'explore', label: 'Explore', element:
}, + { path: 'replay', label: 'Replay', element:
}, +]; + +function renderTabs(initialPath = '/explorer/live', props = {}) { + return render( + + + , + ); +} + +describe('FinchPageTabs Component', () => { + it('renders a link for each tab', () => { + renderTabs(); + expect(screen.getByText('Live')).toBeInTheDocument(); + expect(screen.getByText('Explore')).toBeInTheDocument(); + expect(screen.getByText('Replay')).toBeInTheDocument(); + }); + + it('links each tab to its path beneath the base path', () => { + renderTabs(); + const links = screen.getAllByRole('link'); + expect(links[0]).toHaveAttribute('href', '/explorer/live'); + expect(links[1]).toHaveAttribute('href', '/explorer/explore'); + expect(links[2]).toHaveAttribute('href', '/explorer/replay'); + }); + + it('applies the active class to the tab matching the current path', () => { + renderTabs('/explorer/explore', { classNameActiveTab: 'active-test-class' }); + expect(screen.getByText('Explore').closest('a')).toHaveClass('active-test-class'); + }); + + it('does not apply the active class to the other tabs', () => { + renderTabs('/explorer/explore', { classNameActiveTab: 'active-test-class' }); + expect(screen.getByText('Live').closest('a')).not.toHaveClass('active-test-class'); + }); + + it('applies classNameInactiveTab only to inactive tabs', () => { + renderTabs('/explorer/explore', { classNameInactiveTab: 'inactive-test-class' }); + expect(screen.getByText('Live').closest('a')).toHaveClass('inactive-test-class'); + expect(screen.getByText('Explore').closest('a')).not.toHaveClass('inactive-test-class'); + }); + + it('applies a custom className to the nav element', () => { + const { container } = renderTabs('/explorer/live', { className: 'my-tabs-class' }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); + }); + + it('renders a nav as the root element', () => { + const { container } = renderTabs(); + expect(container.querySelector('nav')).toBeInTheDocument(); + }); + + it('links tabs to a single-slash path when the base path is the root', () => { + render( + + + , + ); + expect(screen.getByText('Live').closest('a')).toHaveAttribute('href', '/live'); + }); + + it('renders no links when tabs is an empty array', () => { + render( + + + , + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); +}); diff --git a/src/testing/tests/useActiveRoute.test.tsx b/src/testing/tests/useActiveRoute.test.tsx new file mode 100644 index 00000000..bf71e236 --- /dev/null +++ b/src/testing/tests/useActiveRoute.test.tsx @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { MemoryRouter } from 'react-router'; +import { useActiveRoute } from '../../hooks/useActiveRoute'; +import { RouteItem } from '../../types/navigationRouterTypes'; + +const withCatchAll: RouteItem[] = [ + { path: '*', label: 'Not found', element:
}, + { path: '/data', label: 'Data', element:
}, +]; + +const withDynamicSibling: RouteItem[] = [ + { path: '/data', label: 'Data', tabs: [{ path: 'live', label: 'Live', element:
}] }, + { path: '/data/:id', label: 'Data Item', element:
}, +]; + +const tabbedRoutes: RouteItem[] = [ + { path: '/data', label: 'Data', tabs: [{ path: 'live', label: 'Live', element:
}] }, +]; + +function activeRouteAt(path: string, routes: RouteItem[]) { + const { result } = renderHook(() => useActiveRoute(routes), { + wrapper: ({ children }) => {children}, + }); + return result.current; +} + +describe('useActiveRoute', () => { + it('ignores a catch-all route when a concrete route matches', () => { + expect(activeRouteAt('/data', withCatchAll)?.label).toBe('Data'); + }); + + it('ranks a tab route above a dynamic sibling that could also match', () => { + expect(activeRouteAt('/data/live', withDynamicSibling)?.label).toBe('Data'); + }); + + it('keeps an unknown tab subpath on its parent route', () => { + expect(activeRouteAt('/data/typo', tabbedRoutes)?.label).toBe('Data'); + }); + + it('returns undefined when no route matches', () => { + expect(activeRouteAt('/nowhere', tabbedRoutes)).toBeUndefined(); + }); +}); diff --git a/src/types/navigationRouterTypes.ts b/src/types/navigationRouterTypes.ts index 3fb53ee6..61f8a6ee 100644 --- a/src/types/navigationRouterTypes.ts +++ b/src/types/navigationRouterTypes.ts @@ -1,11 +1,8 @@ -/** Defines a single navigable route entry in the application router. */ -export type RouteItem = { - /** The URL path for this route (e.g. `"/dashboard"`). */ +type RouteBase = { + /** The URL path for this route (e.g. `"/dashboard"`). Must be static; dynamic segments like `:id` are not supported, since the sidebar links straight to this path. */ path: string; /** Human-readable label shown in navigation UI. */ label: string; - /** The React component rendered when this route is active. */ - element: React.ReactNode; /** Optional icon displayed alongside the route label in navigation. */ icon?: React.ReactNode; /** When `true`, the page background is rendered as transparent against the main content color and sets text color to white, when 'false' it is rendered with white background and default text color*/ @@ -13,3 +10,36 @@ export type RouteItem = { /** Additional CSS classes applied to the inner container of the route element. */ classNameContainer?: string; }; + +/** + * Defines a single navigable route entry in the application router. + * + * A route renders either a single `element` or a strip of `tabs`, never both. + */ +export type RouteItem = RouteBase & + ( + | { + /** The React component rendered when this route is active. */ + element: React.ReactNode; + tabs?: never; + } + | { + element?: never; + /** Tabs rendered in a strip above the page. Each tab becomes a nested route. */ + tabs: RouteTab[]; + } + ); + +/** A single tab belonging to a route that declares `tabs`. */ +export type RouteTab = { + /** Path segment appended to the parent route path (e.g. `"live"` under `"/explorer"`). */ + path: string; + /** Label shown on the tab. */ + label: string; + /** The React component rendered when this tab is active. */ + element: React.ReactNode; + /** When `true`, this tab's background is transparent against the main content color and its text is white. Falls back to the parent route's setting. */ + isBackgroundTransparent?: boolean; + /** Additional CSS classes applied to the inner container of this tab's element. Merged on top of the parent route's. */ + classNameContainer?: string; +}; From b45a6fe54c2b9e5b84879e865c48790ea0e1a4a9 Mon Sep 17 00:00:00 2001 From: Cammille Carinan Date: Wed, 29 Jul 2026 03:15:19 +0200 Subject: [PATCH 2/9] feat(layout): show the active page title in the header --- src/components/FinchAppLayout.tsx | 10 +++ src/components/FinchHeader.tsx | 19 +++++ src/testing/tests/FinchAppLayout.test.tsx | 97 +++++++++++++++++++---- src/testing/tests/FinchHeader.test.tsx | 17 ++++ src/types/navigationRouterTypes.ts | 2 + 5 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/components/FinchAppLayout.tsx b/src/components/FinchAppLayout.tsx index 36eecf3a..01cfa50d 100644 --- a/src/components/FinchAppLayout.tsx +++ b/src/components/FinchAppLayout.tsx @@ -1,6 +1,7 @@ import FinchHeader from '@/components/FinchHeader'; import FinchMainContent from '@/components/FinchMainContent'; import FinchSidebar from '@/components/FinchSidebar'; +import { useActiveRoute } from '@/hooks/useActiveRoute'; import { cn } from '@/lib/utils'; import { RouteItem } from '@/types/navigationRouterTypes'; @@ -12,6 +13,8 @@ export type FinchAppLayoutProps = { headerTitle?: string; /** 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; /** @@ -57,6 +60,7 @@ export default function FinchAppLayout({ classNamePageTabsInactive, classNameHeader, classNameHeaderTitle, + classNameHeaderPageTitle, classNameSidebar, classNameSidebarActiveLink, classNameSidebarInactiveLink, @@ -64,6 +68,10 @@ export default function FinchAppLayout({ className, ...props }: FinchAppLayoutProps) { + const activeRoute = useActiveRoute(routes); + const showPageTitle = activeRoute?.showPageTitle ?? activeRoute?.path !== '/'; + const pageTitle = showPageTitle ? activeRoute?.label : undefined; + return (
{title} + {pageTitle && ( + <> + + + {pageTitle} + + + )}
{rightSlot} diff --git a/src/testing/tests/FinchAppLayout.test.tsx b/src/testing/tests/FinchAppLayout.test.tsx index fe77f536..e120053b 100644 --- a/src/testing/tests/FinchAppLayout.test.tsx +++ b/src/testing/tests/FinchAppLayout.test.tsx @@ -1,7 +1,7 @@ -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'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ @@ -9,10 +9,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 +26,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 +79,81 @@ 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 no header page title at the root route by default', () => { + const rootRoutes: RouteItem[] = [{ path: '/', label: 'Overview', element:
}]; + const { container } = renderLayout({ routes: rootRoutes, path: '/' }); + expect(container.querySelector('header')).not.toHaveTextContent('Overview'); + }); + + it('renders the header page title at the root route when it opts in', () => { + const rootRoutes: RouteItem[] = [ + { path: '/', label: 'Overview', element:
, showPageTitle: true }, + ]; + const { container } = renderLayout({ routes: rootRoutes, path: '/' }); + expect(container.querySelector('header')).toHaveTextContent('Overview'); + }); + + 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:
Details Tab
}], + }, + { path: '/data/:id', label: 'Data Item', element:
Item Page
}, + ]; + const { container } = renderLayout({ + routes: withDynamicSibling, + path: '/data/details', + }); + expect(screen.getByText('Data', { selector: 'header span' })).toBeInTheDocument(); + expect(container.querySelector('nav')).toBeInTheDocument(); + }); + it('renders sidebar links for all routes', () => { const moreRoutes: RouteItem[] = [ { path: '/a', label: 'Alpha', element:
}, { path: '/b', label: 'Beta', element:
}, { path: '/c', label: 'Gamma', element:
}, ]; - render( - - - , - ); - expect(screen.getByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('Beta')).toBeInTheDocument(); - expect(screen.getByText('Gamma')).toBeInTheDocument(); + const { container } = renderLayout({ routes: moreRoutes, path: '/a' }); + const sidebar = within(container.querySelector('aside') as HTMLElement); + expect(sidebar.getByText('Alpha')).toBeInTheDocument(); + expect(sidebar.getByText('Beta')).toBeInTheDocument(); + expect(sidebar.getByText('Gamma')).toBeInTheDocument(); + }); + + it('applies classNamePageTabs to the tab strip', () => { + const tabbedRoutes: RouteItem[] = [ + { + path: '/explorer', + label: 'Explorer', + tabs: [{ path: 'live', label: 'Live', element:
Live Tab
}], + }, + ]; + const { container } = renderLayout({ + routes: tabbedRoutes, + path: '/explorer/live', + classNamePageTabs: 'my-tabs-class', + }); + expect(container.querySelector('nav')).toHaveClass('my-tabs-class'); }); }); diff --git a/src/testing/tests/FinchHeader.test.tsx b/src/testing/tests/FinchHeader.test.tsx index f1712f71..ce003b24 100644 --- a/src/testing/tests/FinchHeader.test.tsx +++ b/src/testing/tests/FinchHeader.test.tsx @@ -57,4 +57,21 @@ describe('FinchHeader Component', () => { const { container } = render(); expect(container.querySelector('img')).toHaveClass('rounded-full'); }); + + it('renders the page title after the title', () => { + render(); + const title = screen.getByText('My Beamline'); + const pageTitle = screen.getByText('Explorer'); + expect(title.compareDocumentPosition(pageTitle)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + }); + + it('renders no page title when pageTitle is not provided', () => { + render(); + expect(screen.queryByText('Explorer')).not.toBeInTheDocument(); + }); + + it('applies classNamePageTitle to the page title element', () => { + render(); + expect(screen.getByText('Explorer')).toHaveClass('text-red-500'); + }); }); diff --git a/src/types/navigationRouterTypes.ts b/src/types/navigationRouterTypes.ts index 61f8a6ee..19db4677 100644 --- a/src/types/navigationRouterTypes.ts +++ b/src/types/navigationRouterTypes.ts @@ -9,6 +9,8 @@ type RouteBase = { isBackgroundTransparent?: boolean; /** Additional CSS classes applied to the inner container of the route element. */ classNameContainer?: string; + /** Whether this route's label is shown in the header. Defaults to `true`, except on `/`. */ + showPageTitle?: boolean; }; /** From 125afec887b86086b4417a013d5aab0bbdd30c3e Mon Sep 17 00:00:00 2001 From: Cammille Carinan Date: Wed, 29 Jul 2026 03:15:27 +0200 Subject: [PATCH 3/9] docs(layout): document page tabs and the header page title --- README.md | 30 ++++++- src/stories/FinchAppLayout.stories.tsx | 119 +++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2806e2f6..4699a191 100644 --- a/README.md +++ b/README.md @@ -184,12 +184,38 @@ export default App; ``` Each route entry supports: -- `path` — the URL path (e.g. `"/controls"`) +- `path` — the URL path, static only (e.g. `"/controls"`) - `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`, except on `/`) + +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. The root route `/` hides it by default, since the app title already names that page. Set `showPageTitle` explicitly to override either way, which is what you want if your landing page is something like `/home`. + +### 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. ## Alternative Installation - Clone This Repo diff --git a/src/stories/FinchAppLayout.stories.tsx b/src/stories/FinchAppLayout.stories.tsx index 08c0fb94..657f054a 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 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, except on "/") + * } + * + * 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, except on "/") +} +\`\`\` + +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[] = [ @@ -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, on every route but \`/\` (override per route with \`showPageTitle\`) 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,15 @@ 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 CustomClasses: Story = { args: { routes: routes, @@ -232,6 +332,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', From 0783e1cd52640173af791c46858e22da061545a6 Mon Sep 17 00:00:00 2001 From: Cammille Carinan Date: Fri, 31 Jul 2026 01:05:04 +0200 Subject: [PATCH 4/9] refactor(layout): group the layout components in a FinchAppLayout folder --- src/app/App.tsx | 2 +- .../{ => FinchAppLayout}/FinchAppLayout.tsx | 8 ++--- .../{ => FinchAppLayout}/FinchHeader.tsx | 0 .../{ => FinchAppLayout}/FinchMainContent.tsx | 6 ++-- .../{ => FinchAppLayout}/FinchPageTabs.tsx | 0 .../{ => FinchAppLayout}/FinchSidebar.tsx | 0 .../FinchAppLayout}/hooks/useActiveRoute.ts | 2 +- .../FinchAppLayout/utils}/pageRoutes.tsx | 0 src/index.ts | 36 +++++++++---------- src/stories/FinchAppLayout.stories.tsx | 4 +-- src/testing/tests/FinchAppLayout.test.tsx | 4 ++- src/testing/tests/FinchHeader.test.tsx | 2 +- src/testing/tests/FinchMainContent.test.tsx | 4 ++- src/testing/tests/FinchPageTabs.test.tsx | 2 +- src/testing/tests/FinchSidebar.test.tsx | 2 +- src/testing/tests/useActiveRoute.test.tsx | 2 +- 16 files changed, 39 insertions(+), 35 deletions(-) rename src/components/{ => FinchAppLayout}/FinchAppLayout.tsx (95%) rename src/components/{ => FinchAppLayout}/FinchHeader.tsx (100%) rename src/components/{ => FinchAppLayout}/FinchMainContent.tsx (94%) rename src/components/{ => FinchAppLayout}/FinchPageTabs.tsx (100%) rename src/components/{ => FinchAppLayout}/FinchSidebar.tsx (100%) rename src/{ => components/FinchAppLayout}/hooks/useActiveRoute.ts (90%) rename src/{lib => components/FinchAppLayout/utils}/pageRoutes.tsx (100%) 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 95% rename from src/components/FinchAppLayout.tsx rename to src/components/FinchAppLayout/FinchAppLayout.tsx index 01cfa50d..4bd45059 100644 --- a/src/components/FinchAppLayout.tsx +++ b/src/components/FinchAppLayout/FinchAppLayout.tsx @@ -1,7 +1,7 @@ -import FinchHeader from '@/components/FinchHeader'; -import FinchMainContent from '@/components/FinchMainContent'; -import FinchSidebar from '@/components/FinchSidebar'; -import { useActiveRoute } from '@/hooks/useActiveRoute'; +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'; diff --git a/src/components/FinchHeader.tsx b/src/components/FinchAppLayout/FinchHeader.tsx similarity index 100% rename from src/components/FinchHeader.tsx rename to src/components/FinchAppLayout/FinchHeader.tsx diff --git a/src/components/FinchMainContent.tsx b/src/components/FinchAppLayout/FinchMainContent.tsx similarity index 94% rename from src/components/FinchMainContent.tsx rename to src/components/FinchAppLayout/FinchMainContent.tsx index 7fe1f62c..8f07bafd 100644 --- a/src/components/FinchMainContent.tsx +++ b/src/components/FinchAppLayout/FinchMainContent.tsx @@ -1,7 +1,7 @@ import { useRoutes } from 'react-router'; -import FinchPageTabs from '@/components/FinchPageTabs'; -import { useActiveRoute } from '@/hooks/useActiveRoute'; -import { buildPageRoutes } from '@/lib/pageRoutes'; +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'; diff --git a/src/components/FinchPageTabs.tsx b/src/components/FinchAppLayout/FinchPageTabs.tsx similarity index 100% rename from src/components/FinchPageTabs.tsx rename to src/components/FinchAppLayout/FinchPageTabs.tsx diff --git a/src/components/FinchSidebar.tsx b/src/components/FinchAppLayout/FinchSidebar.tsx similarity index 100% rename from src/components/FinchSidebar.tsx rename to src/components/FinchAppLayout/FinchSidebar.tsx diff --git a/src/hooks/useActiveRoute.ts b/src/components/FinchAppLayout/hooks/useActiveRoute.ts similarity index 90% rename from src/hooks/useActiveRoute.ts rename to src/components/FinchAppLayout/hooks/useActiveRoute.ts index dae07bd3..b34695d5 100644 --- a/src/hooks/useActiveRoute.ts +++ b/src/components/FinchAppLayout/hooks/useActiveRoute.ts @@ -1,6 +1,6 @@ import { matchRoutes, useLocation } from 'react-router'; -import { buildPageRoutes } from '@/lib/pageRoutes'; +import { buildPageRoutes } from '../utils/pageRoutes'; import { RouteItem } from '@/types/navigationRouterTypes'; /** diff --git a/src/lib/pageRoutes.tsx b/src/components/FinchAppLayout/utils/pageRoutes.tsx similarity index 100% rename from src/lib/pageRoutes.tsx rename to src/components/FinchAppLayout/utils/pageRoutes.tsx diff --git a/src/index.ts b/src/index.ts index 17df1328..8e87723f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,33 +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 FinchPageTabs } from './components/FinchPageTabs'; -export type { FinchPageTabsProps } from './components/FinchPageTabs'; +export { default as FinchPageTabs } from './components/FinchAppLayout/FinchPageTabs'; +export type { FinchPageTabsProps } from './components/FinchAppLayout/FinchPageTabs'; -export { default as FinchSidebar } from './components/FinchSidebar'; -export type { FinchSidebarProps } from './components/FinchSidebar'; +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'; diff --git a/src/stories/FinchAppLayout.stories.tsx b/src/stories/FinchAppLayout.stories.tsx index 657f054a..d3631095 100644 --- a/src/stories/FinchAppLayout.stories.tsx +++ b/src/stories/FinchAppLayout.stories.tsx @@ -1,5 +1,5 @@ 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, ChartScatter } from '@phosphor-icons/react'; import { RouteItem } from '@/types/navigationRouterTypes'; @@ -219,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'; diff --git a/src/testing/tests/FinchAppLayout.test.tsx b/src/testing/tests/FinchAppLayout.test.tsx index e120053b..58336b21 100644 --- a/src/testing/tests/FinchAppLayout.test.tsx +++ b/src/testing/tests/FinchAppLayout.test.tsx @@ -1,7 +1,9 @@ import { render, screen, within } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchAppLayout, { FinchAppLayoutProps } from '../../components/FinchAppLayout'; +import FinchAppLayout, { + FinchAppLayoutProps, +} from '../../components/FinchAppLayout/FinchAppLayout'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ diff --git a/src/testing/tests/FinchHeader.test.tsx b/src/testing/tests/FinchHeader.test.tsx index ce003b24..9c41898b 100644 --- a/src/testing/tests/FinchHeader.test.tsx +++ b/src/testing/tests/FinchHeader.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; -import FinchHeader from '../../components/FinchHeader'; +import FinchHeader from '../../components/FinchAppLayout/FinchHeader'; describe('FinchHeader Component', () => { it('renders without crashing', () => { diff --git a/src/testing/tests/FinchMainContent.test.tsx b/src/testing/tests/FinchMainContent.test.tsx index 330f3774..2c6d44e8 100644 --- a/src/testing/tests/FinchMainContent.test.tsx +++ b/src/testing/tests/FinchMainContent.test.tsx @@ -1,7 +1,9 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchMainContent, { FinchMainContentProps } from '../../components/FinchMainContent'; +import FinchMainContent, { + FinchMainContentProps, +} from '../../components/FinchAppLayout/FinchMainContent'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ diff --git a/src/testing/tests/FinchPageTabs.test.tsx b/src/testing/tests/FinchPageTabs.test.tsx index 790fd9e3..bf649917 100644 --- a/src/testing/tests/FinchPageTabs.test.tsx +++ b/src/testing/tests/FinchPageTabs.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchPageTabs from '../../components/FinchPageTabs'; +import FinchPageTabs from '../../components/FinchAppLayout/FinchPageTabs'; import { RouteTab } from '../../types/navigationRouterTypes'; const mockTabs: RouteTab[] = [ diff --git a/src/testing/tests/FinchSidebar.test.tsx b/src/testing/tests/FinchSidebar.test.tsx index 4ba315a9..afb5a5b5 100644 --- a/src/testing/tests/FinchSidebar.test.tsx +++ b/src/testing/tests/FinchSidebar.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import FinchSidebar from '../../components/FinchSidebar'; +import FinchSidebar from '../../components/FinchAppLayout/FinchSidebar'; import { RouteItem } from '../../types/navigationRouterTypes'; const mockRoutes: RouteItem[] = [ diff --git a/src/testing/tests/useActiveRoute.test.tsx b/src/testing/tests/useActiveRoute.test.tsx index bf71e236..05329bac 100644 --- a/src/testing/tests/useActiveRoute.test.tsx +++ b/src/testing/tests/useActiveRoute.test.tsx @@ -1,7 +1,7 @@ import { renderHook } from '@testing-library/react'; import { describe, it, expect } from 'vitest'; import { MemoryRouter } from 'react-router'; -import { useActiveRoute } from '../../hooks/useActiveRoute'; +import { useActiveRoute } from '../../components/FinchAppLayout/hooks/useActiveRoute'; import { RouteItem } from '../../types/navigationRouterTypes'; const withCatchAll: RouteItem[] = [ From 2a0536c0ab5dbea72a318390c515176b0abadca4 Mon Sep 17 00:00:00 2001 From: Cammille Carinan Date: Fri, 31 Jul 2026 01:05:32 +0200 Subject: [PATCH 5/9] style(layout): use default tailwind sizes for page tabs --- src/components/FinchAppLayout/FinchPageTabs.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/FinchAppLayout/FinchPageTabs.tsx b/src/components/FinchAppLayout/FinchPageTabs.tsx index e38dd824..bc964fb8 100644 --- a/src/components/FinchAppLayout/FinchPageTabs.tsx +++ b/src/components/FinchAppLayout/FinchPageTabs.tsx @@ -16,8 +16,7 @@ export type FinchPageTabsProps = { classNameInactiveTab?: string; }; -const tabStyles = - 'self-end px-[18px] pt-[11px] pb-3 text-[15px] font-medium border-b-[3px] transition-colors'; +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({ @@ -31,7 +30,7 @@ export default function FinchPageTabs({ return (