ui/ux on catalogue
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { StickyRow } from './StickyRow';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
@@ -39,9 +40,19 @@ export interface PageHeaderProps {
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* ── 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) {
|
||||
const hasRow = Boolean(actions || (isTabsInline && tabs));
|
||||
const hasTabsRow = Boolean(tabs && !isTabsInline);
|
||||
|
||||
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
|
||||
place, the column's own padding, and every page matches.
|
||||
*/}
|
||||
{hasRow ? (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
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 />}
|
||||
{hasRow || hasTabsRow ? (
|
||||
<StickyRow>
|
||||
{hasRow ? (
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
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 />}
|
||||
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
|
||||
{hasTabsRow ? <div>{tabs}</div> : null}
|
||||
</StickyRow>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
59
src/components/StickyRow.tsx
Normal file
59
src/components/StickyRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
.trail-map {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
|
||||
@@ -377,27 +377,43 @@ export function CatalogueBrowser({
|
||||
|
||||
It stays above both columns rather than inside the rail: the search
|
||||
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) => (
|
||||
<Token key={filter.key} label={filter.label} size="sm" onRemove={filter.clear} />
|
||||
))}
|
||||
{activeFilters.length > 1 ? (
|
||||
<Button label="Clear all" variant="ghost" size="sm" onClick={clearFilters} />
|
||||
) : 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>
|
||||
|
||||
<div className="catalogue-layout" data-rail={isFiltersOpen ? 'open' : 'closed'}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import type { CatalogueBrand } from '@/api/types';
|
||||
|
||||
const COLLAPSED_COUNT = 8;
|
||||
const COLLAPSED_COUNT = 25;
|
||||
|
||||
/**
|
||||
* The brand rail.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { DeliveryRow, OrderRow, TenantLocation } from '@/api/types';
|
||||
import { KpiCard } from '@/components/KpiCard';
|
||||
import { Tab, TabBar } from '@/components/TabBar';
|
||||
import { StickyRow } from '@/components/StickyRow';
|
||||
import { useSelection, type Selection } from '@/components/useSelection';
|
||||
import { Panel } from '@/components/Panel';
|
||||
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
|
||||
nothing but a tab track is a box drawn round a box. Every other page
|
||||
stands its switcher directly on the page. */}
|
||||
<TabBar aria-label="Dispatch views">
|
||||
<Tab
|
||||
label="By rider"
|
||||
icon={<Bike size={14} />}
|
||||
count={totals.riders || undefined}
|
||||
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' ? (
|
||||
{/* Held under the nav bar while the board scrolls. Dispatch builds its
|
||||
own switcher instead of going through `PageHeader`, so it asks for
|
||||
the behaviour directly — same component, same row, same canvas. */}
|
||||
<StickyRow>
|
||||
<TabBar aria-label="Dispatch views">
|
||||
<Tab
|
||||
label="By customer"
|
||||
icon={<Users size={14} />}
|
||||
isActive={mode === 'customers'}
|
||||
onClick={() => changeMode('customers')}
|
||||
label="By rider"
|
||||
icon={<Bike size={14} />}
|
||||
count={totals.riders || undefined}
|
||||
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:
|
||||
those slice the same stops by whose they are, these report on how
|
||||
the day went. The rule in the track says so. */}
|
||||
<Tab
|
||||
label="Active fleet"
|
||||
icon={<Radio size={14} />}
|
||||
isActive={mode === 'active'}
|
||||
onClick={() => changeMode('active')}
|
||||
isGroupStart
|
||||
isLive
|
||||
/>
|
||||
<Tab
|
||||
label="Plan vs actual"
|
||||
icon={<Timer size={14} />}
|
||||
isActive={mode === 'timing'}
|
||||
onClick={() => changeMode('timing')}
|
||||
/>
|
||||
<Tab
|
||||
label="Unit economics"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={mode === 'profit'}
|
||||
onClick={() => changeMode('profit')}
|
||||
/>
|
||||
</TabBar>
|
||||
{/* The three below group the day differently from the three above:
|
||||
those slice the same stops by whose they are, these report on how
|
||||
the day went. The rule in the track says so. */}
|
||||
<Tab
|
||||
label="Active fleet"
|
||||
icon={<Radio size={14} />}
|
||||
isActive={mode === 'active'}
|
||||
onClick={() => changeMode('active')}
|
||||
isGroupStart
|
||||
isLive
|
||||
/>
|
||||
<Tab
|
||||
label="Plan vs actual"
|
||||
icon={<Timer size={14} />}
|
||||
isActive={mode === 'timing'}
|
||||
onClick={() => changeMode('timing')}
|
||||
/>
|
||||
<Tab
|
||||
label="Unit economics"
|
||||
icon={<IndianRupee size={14} />}
|
||||
isActive={mode === 'profit'}
|
||||
onClick={() => changeMode('profit')}
|
||||
/>
|
||||
</TabBar>
|
||||
</StickyRow>
|
||||
|
||||
|
||||
{/* The same four-card row every other page uses — same `kpi-grid`, same
|
||||
|
||||
@@ -115,6 +115,20 @@
|
||||
--color-canvas-mid: #f8f8f8;
|
||||
--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-wide: 108rem;
|
||||
--container-admin: 102rem;
|
||||
@@ -137,15 +151,7 @@ body::before {
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-canvas-violet) 0%,
|
||||
#f5f2f7 28%,
|
||||
#f7f6f7 45%,
|
||||
var(--color-canvas-mid) 65%,
|
||||
#f8f7f4 86%,
|
||||
var(--color-canvas-warm) 100%
|
||||
);
|
||||
background: var(--canvas-gradient);
|
||||
}
|
||||
|
||||
/* Every application shell is transparent so the canvas reads through. */
|
||||
@@ -843,11 +849,14 @@ main {
|
||||
top: 72px;
|
||||
/* The clip lives here, on the element that owns the radius. */
|
||||
overflow: hidden;
|
||||
height: calc(100dvh - 98px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rail-scroll {
|
||||
padding: 6px;
|
||||
max-height: calc(100dvh - 98px);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
/* 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
|
||||
@@ -2312,3 +2321,62 @@ main {
|
||||
.fleet-actions > *:first-child button {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user