ui/ux on catalogue

This commit is contained in:
2026-09-18 19:19:34 +05:30
parent 041dc37861
commit 93735e232a
7 changed files with 259 additions and 94 deletions

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { StickyRow } from './StickyRow';
export interface PageHeaderProps { export interface PageHeaderProps {
title: string; title: string;
@@ -39,9 +40,19 @@ export interface PageHeaderProps {
* *
* The tabs' spacing lives here rather than at each call site, so the five pages * The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again. * that have tabs cannot drift apart from each other again.
*
* ── Held under the nav bar ──────────────────────────────────────────────────
*
* This row does not scroll away. It used to: reaching the bottom of a long list
* put the tab you were in, the search and every action button off the top of
* the window, so the only way back to the controls over what you were reading
* was to scroll back through all of it. The page still scrolls normally —
* nothing here gets a scrollbar of its own — the row simply stays. The
* behaviour and the canvas it paints live in `StickyRow` and `.page-sticky`.
*/ */
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) { export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
const hasRow = Boolean(actions || (isTabsInline && tabs)); const hasRow = Boolean(actions || (isTabsInline && tabs));
const hasTabsRow = Boolean(tabs && !isTabsInline);
return ( return (
<> <>
@@ -64,29 +75,33 @@ export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderPro
below the nav bar while Inventory sat at 24. The gap now comes from one below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches. place, the column's own padding, and every page matches.
*/} */}
{hasRow ? ( {hasRow || hasTabsRow ? (
<header <StickyRow>
style={{ {hasRow ? (
display: 'flex', <header
flexWrap: 'wrap', style={{
alignItems: 'center', display: 'flex',
justifyContent: 'space-between', flexWrap: 'wrap',
gap: 16, alignItems: 'center',
}} justifyContent: 'space-between',
> gap: 16,
{/* Left of the row when inline, so the tabs start at the page's left }}
edge and the actions stay on the right. */} >
{isTabsInline && tabs ? <div>{tabs}</div> : <span />} {/* Left of the row when inline, so the tabs start at the page's
left edge and the actions stay on the right. */}
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
{actions ? ( {actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions} {actions}
</div> </div>
) : null}
</header>
) : null} ) : null}
</header>
) : null}
{tabs && !isTabsInline ? <div>{tabs}</div> : null} {hasTabsRow ? <div>{tabs}</div> : null}
</StickyRow>
) : null}
</> </>
); );
} }

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
/**
* A row of page controls held under the nav bar while the page scrolls.
*
* Whatever sits above a page — its view tabs, its search, its action buttons —
* used to leave with the scroll. Reaching the bottom of a long list put every
* control over that list off the top of the window, and the only way back to
* them was to scroll back through everything just read. The further in somebody
* was, the further from the controls.
*
* The page still scrolls normally. Nothing here gets a scrollbar of its own —
* the row simply stays where it is. Positioning and the canvas it paints live
* in `.page-sticky`; this component exists for the one part CSS cannot answer,
* which is whether the row is currently pinned.
*
* `PageHeader` wraps itself in this, so every page built from it gets the
* behaviour without asking. Dispatch builds its own switcher rather than going
* through `PageHeader`, so it wraps itself — which is the reason this is a
* component and not four more lines inside the header.
*/
export function StickyRow({ children }: { children: ReactNode }) {
/**
* Pinned or not, so the hairline can be drawn only when it is.
*
* At rest the nav bar's own hairline sits just above this one, and two
* parallel lines across the page with nothing between them read as a mistake.
* The line is what separates the row from content passing UNDER it, so it
* belongs to the pinned state rather than to the row.
*
* Watched with an observer rather than a scroll handler: a scroll listener
* runs on every frame of every scroll to answer a question that changes
* twice. The negative top margin puts the observer's boundary a pixel above
* where the row comes to rest, so "no longer fully inside that box" means
* exactly "stuck".
*/
const rowRef = useRef<HTMLDivElement>(null);
const [isStuck, setIsStuck] = useState(false);
useEffect(() => {
const element = rowRef.current;
/* Guarded for any renderer without it — the row still sticks, it just
never grows its line, which is a missing hairline rather than a crash. */
if (!element || typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
([entry]) => setIsStuck(Boolean(entry && !entry.isIntersecting)),
{ rootMargin: '-57px 0px 0px 0px', threshold: 1 },
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div ref={rowRef} className="page-sticky" data-stuck={isStuck ? 'true' : 'false'}>
{children}
</div>
);
}

View File

@@ -8,6 +8,7 @@
.trail-map { .trail-map {
position: relative; position: relative;
z-index: 0;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View File

@@ -377,27 +377,43 @@ export function CatalogueBrowser({
It stays above both columns rather than inside the rail: the search It stays above both columns rather than inside the rail: the search
narrows the whole catalogue, and the rail only lists brands. */} narrows the whole catalogue, and the rail only lists brands. */}
<HStack gap={1} align="center" wrap="wrap" justify="end"> {/* Pinned to the right-hand corner, and stated inline so it cannot be
undone by a stylesheet.
`justify="end"` was already here and already resolves to
`justify-content: flex-end` — HStack maps `justify` to the main axis,
and the rule is in the bundle — yet the row was reported rendering at
the top LEFT of the platform catalogue. A utility class landing after
the component's own is the only thing that explains that, and an
inline declaration is the one thing such a class cannot outrank.
`width` is explicit for the same reason rather than relying on the
flex default. */}
<HStack gap={1} align="center" wrap="wrap" justify="start" width="100%">
<div style={{ width: 216, display: 'flex', gap: 8, alignItems: 'center' }}>
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<SearchInput
label="Search the catalogue"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width="full"
/>
</div>
</div>
{activeFilters.map((filter) => ( {activeFilters.map((filter) => (
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} /> <Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
))} ))}
{activeFilters.length > 1 ? ( {activeFilters.length > 1 ? (
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} /> <Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
) : null} ) : null}
<IconButton
label={isFiltersOpen ? 'Hide filters' : 'Show filters'}
icon={<Funnel size={15} />}
variant={isFiltersOpen ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setIsFiltersOpen((open) => !open)}
/>
<SearchInput
label="Search the catalogue"
value={keyword}
onChange={setKeyword}
placeholder="Search products…"
width={220}
/>
</HStack> </HStack>
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}> <div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>

View File

@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
import { ChevronDown } from 'lucide-react'; import { ChevronDown } from 'lucide-react';
import type { CatalogueBrand } from '@/api/types'; import type { CatalogueBrand } from '@/api/types';
const COLLAPSED_COUNT = 8; const COLLAPSED_COUNT = 25;
/** /**
* The brand rail. * The brand rail.

View File

@@ -22,6 +22,7 @@ import {
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types'; import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
import { KpiCard } from '@/components/KpiCard'; import { KpiCard } from '@/components/KpiCard';
import { Tab, TabBar } from '@/components/TabBar'; import { Tab, TabBar } from '@/components/TabBar';
import { StickyRow } from '@/components/StickyRow';
import { useSelection, type Selection } from '@/components/useSelection'; import { useSelection, type Selection } from '@/components/useSelection';
import { Panel } from '@/components/Panel'; import { Panel } from '@/components/Panel';
import { TablePager } from '@/components/TablePager'; import { TablePager } from '@/components/TablePager';
@@ -244,57 +245,62 @@ export function DispatchBoard({ scope }: { scope: DispatchScope }) {
operations cluster beside it was removed earlier — and a card holding operations cluster beside it was removed earlier — and a card holding
nothing but a tab track is a box drawn round a box. Every other page nothing but a tab track is a box drawn round a box. Every other page
stands its switcher directly on the page. */} stands its switcher directly on the page. */}
<TabBar aria-label="Dispatch views"> {/* Held under the nav bar while the board scrolls. Dispatch builds its
<Tab own switcher instead of going through `PageHeader`, so it asks for
label="By rider" the behaviour directly — same component, same row, same canvas. */}
icon={<Bike size={14} />} <StickyRow>
count={totals.riders || undefined} <TabBar aria-label="Dispatch views">
isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/>
<Tab
label="By store"
icon={<Store size={14} />}
count={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
{/* A shop's customer book is its own. Grouping a partner's rounds by
customer would mean reading every merchant's book to put a name to
a phone number, so the tab is not offered on a partner board. */}
{scope.kind === 'tenant' ? (
<Tab <Tab
label="By customer" label="By rider"
icon={<Users size={14} />} icon={<Bike size={14} />}
isActive={mode === 'customers'} count={totals.riders || undefined}
onClick={() => changeMode('customers')} isActive={mode === 'riders'}
onClick={() => changeMode('riders')}
/> />
) : null} <Tab
label="By store"
icon={<Store size={14} />}
count={branches.length || undefined}
isActive={mode === 'stores'}
onClick={() => changeMode('stores')}
/>
{/* A shop's customer book is its own. Grouping a partner's rounds by
customer would mean reading every merchant's book to put a name to
a phone number, so the tab is not offered on a partner board. */}
{scope.kind === 'tenant' ? (
<Tab
label="By customer"
icon={<Users size={14} />}
isActive={mode === 'customers'}
onClick={() => changeMode('customers')}
/>
) : null}
{/* The three below group the day differently from the three above: {/* The three below group the day differently from the three above:
those slice the same stops by whose they are, these report on how those slice the same stops by whose they are, these report on how
the day went. The rule in the track says so. */} the day went. The rule in the track says so. */}
<Tab <Tab
label="Active fleet" label="Active fleet"
icon={<Radio size={14} />} icon={<Radio size={14} />}
isActive={mode === 'active'} isActive={mode === 'active'}
onClick={() => changeMode('active')} onClick={() => changeMode('active')}
isGroupStart isGroupStart
isLive isLive
/> />
<Tab <Tab
label="Plan vs actual" label="Plan vs actual"
icon={<Timer size={14} />} icon={<Timer size={14} />}
isActive={mode === 'timing'} isActive={mode === 'timing'}
onClick={() => changeMode('timing')} onClick={() => changeMode('timing')}
/> />
<Tab <Tab
label="Unit economics" label="Unit economics"
icon={<IndianRupee size={14} />} icon={<IndianRupee size={14} />}
isActive={mode === 'profit'} isActive={mode === 'profit'}
onClick={() => changeMode('profit')} onClick={() => changeMode('profit')}
/> />
</TabBar> </TabBar>
</StickyRow>
{/* The same four-card row every other page uses — same `kpi-grid`, same {/* The same four-card row every other page uses — same `kpi-grid`, same

View File

@@ -115,6 +115,20 @@
--color-canvas-mid: #f8f8f8; --color-canvas-mid: #f8f8f8;
--color-canvas-warm: #f8f6f1; --color-canvas-warm: #f8f6f1;
/* The canvas itself, named once. `body::before` paints it across the
viewport; anything that has to sit on top of it and vanish paints the
same gradient with `background-attachment: fixed` -- see `.page-sticky`.
Two copies of these stops would drift the first time one is tuned. */
--canvas-gradient: linear-gradient(
90deg,
var(--color-canvas-violet) 0%,
#f5f2f7 28%,
#f7f6f7 45%,
var(--color-canvas-mid) 65%,
#f8f7f4 86%,
var(--color-canvas-warm) 100%
);
--container-page: 80rem; --container-page: 80rem;
--container-wide: 108rem; --container-wide: 108rem;
--container-admin: 102rem; --container-admin: 102rem;
@@ -137,15 +151,7 @@ body::before {
inset: 0; inset: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: linear-gradient( background: var(--canvas-gradient);
90deg,
var(--color-canvas-violet) 0%,
#f5f2f7 28%,
#f7f6f7 45%,
var(--color-canvas-mid) 65%,
#f8f7f4 86%,
var(--color-canvas-warm) 100%
);
} }
/* Every application shell is transparent so the canvas reads through. */ /* Every application shell is transparent so the canvas reads through. */
@@ -843,11 +849,14 @@ main {
top: 72px; top: 72px;
/* The clip lives here, on the element that owns the radius. */ /* The clip lives here, on the element that owns the radius. */
overflow: hidden; overflow: hidden;
height: calc(100dvh - 98px);
display: flex;
flex-direction: column;
} }
.rail-scroll { .rail-scroll {
padding: 6px; padding: 6px;
max-height: calc(100dvh - 98px); flex: 1;
overflow-y: auto; overflow-y: auto;
/* The thin, quiet scrollbar this rail used to declare for itself is now the /* The thin, quiet scrollbar this rail used to declare for itself is now the
whole console's — both scrollbar properties are inherited, so the pair on whole console's — both scrollbar properties are inherited, so the pair on
@@ -2312,3 +2321,62 @@ main {
.fleet-actions > *:first-child button { .fleet-actions > *:first-child button {
justify-content: center; justify-content: center;
} }
/* ── The page's own header row, held under the nav bar ──────────────────────
The tabs and the buttons above a page used to leave with the scroll. Getting
to the bottom of a long list meant the controls over that list -- the tab you
were in, the search, the action buttons -- were off the top of the window,
and the only way back to them was to scroll back through everything you had
just read. The further in you were, the further from every control.
So the row stays. The page scrolls underneath it exactly as before; nothing
is given its own scrollbar.
── Why the background is a gradient and not a colour ──
The canvas is a FIXED, viewport-wide gradient painted on `body::before`, so
there is no single colour this row could be filled with -- a flat fill would
read as a band sitting on the page, lighter or darker than the canvas
depending which third of the window it is in.
`background-attachment: fixed` makes the positioning area the viewport rather
than this element, which is the same frame `body::before` uses. The row then
paints the exact slice of canvas it is covering, and matches at any width and
any scroll position. The gradient is a variable so the two cannot drift.
The offset is the nav bar's own 56px, and the z-index sits between the page's
content (3 at most) and that bar (40), so it covers rows on the way past and
is itself covered by the bar above and any drawer. */
.page-sticky {
position: sticky;
top: 56px;
z-index: 20;
display: flex;
flex-direction: column;
/* What the two rows had as siblings of the page column. */
gap: 24px;
background: var(--canvas-gradient) fixed;
/* A band of canvas above and below, so a row does not touch the controls as
it passes under them. The negative margins hand the same amount back to the
column, leaving the resting layout exactly as it was -- this costs no
vertical space until it is actually stuck. */
padding-block: 12px;
margin-block: -12px;
}
/* The nav bar carries a hairline; a second one at rest would be two lines 24px
apart. It appears only once something has scrolled beneath. */
.page-sticky::after {
content: '';
position: absolute;
inset: auto 0 0 0;
height: 1px;
background: var(--color-line);
opacity: 0;
transition: opacity 140ms ease;
}
.page-sticky[data-stuck='true']::after {
opacity: 1;
}