enhancement
This commit is contained in:
@@ -2,23 +2,32 @@ import type { ReactNode } from 'react';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
|
||||
count?: string;
|
||||
/** One line of context. */
|
||||
description?: string;
|
||||
/** Right-aligned actions, wrapping. */
|
||||
actions?: ReactNode;
|
||||
/** An optional tabs row directly under the header rule. */
|
||||
tabs?: ReactNode;
|
||||
isLive?: boolean;
|
||||
/**
|
||||
* Put the tabs on the SAME line as the actions — tabs left, actions right —
|
||||
* instead of on a row of their own beneath.
|
||||
*
|
||||
* Worth having now that the page titles are gone: with nothing above them the
|
||||
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
|
||||
* one gives the page back a row without crowding anything.
|
||||
*/
|
||||
isTabsInline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page frame header, built to KROW's `AdminPage` spec.
|
||||
*
|
||||
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
|
||||
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
|
||||
* rhythm.
|
||||
* Actions right-aligned and wrapping, with an optional tabs row beneath.
|
||||
* Everything the page stacks below sits on a 24px rhythm.
|
||||
*
|
||||
* The visible title, count, Live pill and description are gone from every page
|
||||
* in all three workspaces. They restated what the chrome already says — the top
|
||||
* bar names the section, the branch picker gives the count — and the
|
||||
* description was a line of prose above data that nobody reads twice. The title
|
||||
* survives as a screen-reader-only h1; see the note at the call.
|
||||
*
|
||||
* There was a hairline rule under all of this, with 16px of padding above it
|
||||
* and another 12px below before the tabs — 28px of nothing plus a line, on
|
||||
@@ -31,87 +40,53 @@ 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.
|
||||
*/
|
||||
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
|
||||
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
|
||||
const hasRow = Boolean(actions || (isTabsInline && tabs));
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
|
||||
<h1
|
||||
className="page-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'var(--font-display)',
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
color: 'var(--color-ink-1)',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
{/*
|
||||
The title is still here, and still read aloud — it is just not drawn.
|
||||
|
||||
{count ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: 'var(--color-ink-4)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
Removing it visually was the ask; removing it outright would leave every
|
||||
page in the console with no h1, no document outline and nothing for a
|
||||
screen reader to announce on navigation. `sr-only` is absolutely
|
||||
positioned, so it is out of flow and costs no layout.
|
||||
*/}
|
||||
<h1 className="sr-only">{title}</h1>
|
||||
|
||||
{isLive ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand-tint)',
|
||||
color: 'var(--color-brand)',
|
||||
padding: '2px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: 'var(--color-brand)',
|
||||
}}
|
||||
/>
|
||||
Live
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/*
|
||||
No row at all when there is nothing to put in it.
|
||||
|
||||
{description ? (
|
||||
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
|
||||
{description}
|
||||
</p>
|
||||
It used to render an empty <header> regardless. At zero height that
|
||||
looks free, but it is still a flex child, so it collected the column's
|
||||
12px gap and pushed everything down — a page with no actions sat 36px
|
||||
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 />}
|
||||
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
|
||||
{actions ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
|
||||
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { ROLE_LABEL } from '@/auth/roles';
|
||||
import { AssistantPanel } from './AssistantPanel';
|
||||
import { DateScopePicker } from './DateScope';
|
||||
|
||||
export interface NavEntry {
|
||||
to: string;
|
||||
@@ -133,14 +134,26 @@ export function AppShell({
|
||||
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
|
||||
on exactly the same left edge as the page title below them at every
|
||||
width, including a 2560px monitor where the body is centred. */}
|
||||
{/*
|
||||
Opaque, with no backdrop blur.
|
||||
|
||||
The blur cost more than it bought the moment a popover moved into this
|
||||
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
|
||||
`position: fixed` descendant, and the design system's popovers are fixed
|
||||
and CSS-anchor-positioned — so the date picker's calendar rendered
|
||||
inside the header at 0×0 and could not be opened at all. The same
|
||||
control worked perfectly two pixels lower, on the page.
|
||||
|
||||
A solid background is the fix rather than a hack around it: the bar sits
|
||||
on a near-white page, so at 85% opacity plus blur it was already almost
|
||||
opaque, and nothing here reads differently for losing it.
|
||||
*/}
|
||||
<header
|
||||
style={{
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 40,
|
||||
background: 'rgba(255,255,255,.85)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-line)',
|
||||
}}
|
||||
>
|
||||
@@ -247,6 +260,13 @@ export function AppShell({
|
||||
the catalogue are real and stay. A control that looks like it
|
||||
works costs more trust than a missing one. */}
|
||||
|
||||
{/* The date filter, beside the profile and common to every page.
|
||||
|
||||
Rendered here rather than by each page so the two questions the
|
||||
console asks — which shop, and when — are both answered in the
|
||||
chrome, and so the answer survives navigation. */}
|
||||
<DateScopePicker />
|
||||
|
||||
{headerActions}
|
||||
|
||||
{/* No notification bell. It was labelled "2 unread" with the dot
|
||||
|
||||
85
src/components/shell/DateScope.tsx
Normal file
85
src/components/shell/DateScope.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { DateRange } from '@/api/insights';
|
||||
import {
|
||||
DateRangePicker,
|
||||
presetRange,
|
||||
type RangePreset,
|
||||
} from '@/features/store-admin/DateRangePicker';
|
||||
|
||||
/**
|
||||
* The console's date filter, held once for the whole workspace.
|
||||
*
|
||||
* It sits in the top bar beside the profile, not on the page, and every page
|
||||
* reads it from here — the same relationship `BranchScope` already has to the
|
||||
* branch picker beside the logo. The two scopes now work the same way: the two
|
||||
* questions every page is asked, "which shop" and "when", are answered once in
|
||||
* the chrome rather than re-answered on each screen.
|
||||
*
|
||||
* ── What this changes about the pages ───────────────────────────────────────
|
||||
*
|
||||
* The range survives navigation. Setting March on Sales and clicking through to
|
||||
* Reports shows March, which is what somebody looking into a month actually
|
||||
* wants and is the whole reason for lifting it. It also means the live boards —
|
||||
* Console, Counters, Terminals — no longer force themselves back to today; they
|
||||
* follow the shared range like everything else. That is the trade the move
|
||||
* makes, and it is the right one, but it IS a change: those three used to be
|
||||
* pinned to the day whatever else you had chosen.
|
||||
*/
|
||||
|
||||
export interface DateScopeValue {
|
||||
preset: RangePreset;
|
||||
range: DateRange;
|
||||
set: (preset: RangePreset, range: DateRange) => void;
|
||||
/** Back to no filter at all — what an empty state offers as a way out. */
|
||||
clear: () => void;
|
||||
/** True when a range is actually narrowing the page. */
|
||||
isFiltered: boolean;
|
||||
}
|
||||
|
||||
/** Where the console starts. Month to date is what most pages want. */
|
||||
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
|
||||
|
||||
const DateScopeContext = createContext<DateScopeValue | null>(null);
|
||||
|
||||
export function DateScopeProvider({ children }: { children: ReactNode }) {
|
||||
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
|
||||
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
|
||||
|
||||
const value = useMemo<DateScopeValue>(
|
||||
() => ({
|
||||
preset,
|
||||
range,
|
||||
set: (nextPreset, nextRange) => {
|
||||
setPreset(nextPreset);
|
||||
setRange(nextRange);
|
||||
},
|
||||
clear: () => {
|
||||
setPreset('custom');
|
||||
setRange({});
|
||||
},
|
||||
isFiltered: Boolean(range.fromdate || range.todate),
|
||||
}),
|
||||
[preset, range],
|
||||
);
|
||||
|
||||
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared range.
|
||||
*
|
||||
* Throws outside the provider rather than inventing a local range: a page that
|
||||
* silently filtered on its own dates while the bar showed something else would
|
||||
* be the exact confusion this exists to remove.
|
||||
*/
|
||||
export function useDateScope(): DateScopeValue {
|
||||
const value = useContext(DateScopeContext);
|
||||
if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The control itself. Rendered once, in the top bar. */
|
||||
export function DateScopePicker() {
|
||||
const dates = useDateScope();
|
||||
return <DateRangePicker preset={dates.preset} range={dates.range} onChange={dates.set} />;
|
||||
}
|
||||
Reference in New Issue
Block a user