@12-apps/ui 3.3.0 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components-catalog.md +8 -0
- package/package.json +2 -1
- package/src/components/navigation/AppHeader/AppHeader.brand.tsx +102 -0
- package/src/components/navigation/AppHeader/AppHeader.colors.ts +120 -0
- package/src/components/navigation/AppHeader/AppHeader.details.tsx +227 -0
- package/src/components/navigation/AppHeader/AppHeader.identity.tsx +251 -0
- package/src/components/navigation/AppHeader/AppHeader.md +138 -0
- package/src/components/navigation/AppHeader/AppHeader.tsx +232 -0
- package/src/components/navigation/AppHeader/AppHeader.types.ts +190 -0
- package/src/components/navigation/AppHeader/index.ts +16 -0
- package/src/components/navigation/index.ts +1 -0
package/components-catalog.md
CHANGED
|
@@ -569,6 +569,14 @@ Components for structuring and organizing page layouts.
|
|
|
569
569
|
|
|
570
570
|
Components for site and app navigation.
|
|
571
571
|
|
|
572
|
+
### AppHeader
|
|
573
|
+
**Purpose**: The application bar — brand identity, state line, actions, and the details panel behind them
|
|
574
|
+
**Use Cases**: Storefront header, back-office top bar, platform console header; any app bar that must serve a phone and a desktop from one component
|
|
575
|
+
**Features**: Slot-based (`leading` / identity / `actions` / `meta` / `below`), context-agnostic (no router, session or data), brand mark with a gradient derived from ONE seed colour, dotted state line, disclosure panel that is a bottom sheet on phones and a centred dialog on large screens, sticky/fixed positioning with a self-measured spacer
|
|
576
|
+
**Types**: `AppHeaderProps`, `AppHeaderIdentityProps`, `AppHeaderBrandProps`, `AppHeaderStatusProps`, `AppHeaderDetailsProps`, `AppHeaderDetailRow`
|
|
577
|
+
**Location**: `src/components/navigation/AppHeader`
|
|
578
|
+
**Docs**: `src/components/navigation/AppHeader/AppHeader.md`
|
|
579
|
+
|
|
572
580
|
### Breadcrumbs
|
|
573
581
|
**Purpose**: Display navigation path
|
|
574
582
|
**Use Cases**: Hierarchical navigation, page location indicator
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"./layout/Skeleton": "./src/components/layout/Skeleton/index.ts",
|
|
121
121
|
"./layout/Spacer": "./src/components/layout/Spacer/index.ts",
|
|
122
122
|
"./layout/TableFilter": "./src/components/layout/TableFilter/index.ts",
|
|
123
|
+
"./navigation/AppHeader": "./src/components/navigation/AppHeader/index.ts",
|
|
123
124
|
"./navigation/Breadcrumbs": "./src/components/navigation/Breadcrumbs/index.ts",
|
|
124
125
|
"./navigation/CommandPalette": "./src/components/navigation/CommandPalette/index.ts",
|
|
125
126
|
"./navigation/ContextMenu": "./src/components/navigation/ContextMenu/index.ts",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, useTheme } from '@mui/material';
|
|
4
|
+
import type { Theme } from '@mui/material/styles';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import type { SizeValue } from '../../../tokens/scales';
|
|
8
|
+
|
|
9
|
+
import { brandGradient, initialsOf } from './AppHeader.colors';
|
|
10
|
+
import type { AppHeaderBrandProps } from './AppHeader.types';
|
|
11
|
+
|
|
12
|
+
/** The mark's edge, per house size step. */
|
|
13
|
+
const MARK_PX: Record<SizeValue, number> = { xs: 24, sm: 32, md: 40, lg: 48, xl: 56 };
|
|
14
|
+
|
|
15
|
+
/** Initials are set at 40% of the mark so a two-letter pair never touches the edge. */
|
|
16
|
+
const INITIALS_RATIO = 0.4;
|
|
17
|
+
|
|
18
|
+
/** The mark's box, lifted out of the component to keep it inside the complexity bar. */
|
|
19
|
+
function markSx(
|
|
20
|
+
theme: Theme,
|
|
21
|
+
{ edge, round, showLogo, seed }: { edge: number; round: boolean; showLogo: boolean; seed: string },
|
|
22
|
+
): Record<string, unknown> {
|
|
23
|
+
return {
|
|
24
|
+
width: edge,
|
|
25
|
+
height: edge,
|
|
26
|
+
flex: '0 0 auto',
|
|
27
|
+
overflow: 'hidden',
|
|
28
|
+
borderRadius: round ? '50%' : '30%',
|
|
29
|
+
display: 'flex',
|
|
30
|
+
alignItems: 'center',
|
|
31
|
+
justifyContent: 'center',
|
|
32
|
+
// Only the initials need the gradient; a logo covers it entirely, and
|
|
33
|
+
// painting it underneath would show at the corners of a transparent PNG.
|
|
34
|
+
backgroundImage: showLogo ? 'none' : brandGradient(seed),
|
|
35
|
+
backgroundColor: showLogo ? theme.palette.action.hover : 'transparent',
|
|
36
|
+
color: theme.palette.getContrastText(seed),
|
|
37
|
+
fontSize: Math.round(edge * INITIALS_RATIO),
|
|
38
|
+
fontWeight: 700,
|
|
39
|
+
lineHeight: 1,
|
|
40
|
+
letterSpacing: '0.02em',
|
|
41
|
+
userSelect: 'none',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The mark that opens the header: the logo when there is one, the name's
|
|
47
|
+
* initials on a gradient derived from the brand colour when there is not.
|
|
48
|
+
*
|
|
49
|
+
* Both branches occupy the SAME box, so a header does not resize when a logo
|
|
50
|
+
* arrives — or when one fails to load and the initials take over, which is the
|
|
51
|
+
* case a plain `<img>` renders as a torn-page glyph beside the store's name.
|
|
52
|
+
*/
|
|
53
|
+
export const AppHeaderBrand: React.FC<AppHeaderBrandProps> = ({
|
|
54
|
+
name,
|
|
55
|
+
logoUrl,
|
|
56
|
+
seedColor,
|
|
57
|
+
size = 'md',
|
|
58
|
+
shape = 'rounded',
|
|
59
|
+
className,
|
|
60
|
+
dataTestId = 'app-header-brand',
|
|
61
|
+
}) => {
|
|
62
|
+
const theme = useTheme();
|
|
63
|
+
const [broken, setBroken] = React.useState(false);
|
|
64
|
+
// Keyed on the URL so a store that swaps its logo gets a fresh attempt rather
|
|
65
|
+
// than inheriting the previous one's failure.
|
|
66
|
+
React.useEffect(() => setBroken(false), [logoUrl]);
|
|
67
|
+
|
|
68
|
+
const edge = MARK_PX[size];
|
|
69
|
+
const seed = seedColor || theme.palette.primary.main;
|
|
70
|
+
const showLogo = Boolean(logoUrl) && !broken;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Box
|
|
74
|
+
className={className}
|
|
75
|
+
data-testid={dataTestId}
|
|
76
|
+
// `role="img"` + the name: the initials are a picture of the brand, not
|
|
77
|
+
// two letters a screen reader should spell out.
|
|
78
|
+
role="img"
|
|
79
|
+
aria-label={name}
|
|
80
|
+
sx={markSx(theme, { edge, round: shape === 'circle', showLogo, seed })}
|
|
81
|
+
>
|
|
82
|
+
{showLogo ? (
|
|
83
|
+
<Box
|
|
84
|
+
component="img"
|
|
85
|
+
src={logoUrl ?? undefined}
|
|
86
|
+
alt=""
|
|
87
|
+
onError={() => setBroken(true)}
|
|
88
|
+
data-testid={`${dataTestId}-logo`}
|
|
89
|
+
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
90
|
+
/>
|
|
91
|
+
) : (
|
|
92
|
+
<Box component="span" aria-hidden data-testid={`${dataTestId}-initials`}>
|
|
93
|
+
{initialsOf(name)}
|
|
94
|
+
</Box>
|
|
95
|
+
)}
|
|
96
|
+
</Box>
|
|
97
|
+
);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
AppHeaderBrand.displayName = 'AppHeaderBrand';
|
|
101
|
+
|
|
102
|
+
export default AppHeaderBrand;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { decomposeColor } from '@mui/material/styles';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Turning ONE brand colour into the mark's fill.
|
|
5
|
+
*
|
|
6
|
+
* The alternative — a second "highlight" prop — reads reasonable and is a trap:
|
|
7
|
+
* it asks every caller to pick a colour that pairs with a colour they already
|
|
8
|
+
* picked, and a multi-tenant app asks that of people who will never see this
|
|
9
|
+
* component. Most would leave it unset, some would set it to the same value
|
|
10
|
+
* (a gradient that is a flat fill), and the rest would get a pairing nobody
|
|
11
|
+
* checked. So the highlight is DERIVED, and a store configures one hex.
|
|
12
|
+
*
|
|
13
|
+
* The derivation moves the seed's HUE rather than only its lightness. A
|
|
14
|
+
* lighten-only gradient reads as a lit sphere — the same effect at every seed,
|
|
15
|
+
* and a weak one at the light end where there is little room left to lighten.
|
|
16
|
+
* Rotating toward the warm side gives the mark a direction the eye follows, and
|
|
17
|
+
* because the rotation is fixed the two stops stay recognisably one colour.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Degrees the highlight is rotated from the seed. Warm, and short of a clash. */
|
|
21
|
+
const HIGHLIGHT_HUE_SHIFT = 42;
|
|
22
|
+
|
|
23
|
+
/** The highlight sits this much lighter than the seed. */
|
|
24
|
+
const HIGHLIGHT_LIGHTEN = 0.12;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A near-grey seed has no hue to rotate, so rotating it produces the same grey
|
|
28
|
+
* twice. Below this saturation the mark falls back to a lightness-only ramp,
|
|
29
|
+
* which is the honest rendering of "this brand has no colour".
|
|
30
|
+
*/
|
|
31
|
+
const HUELESS = 0.08;
|
|
32
|
+
|
|
33
|
+
/** The angle light appears to come from — matches the house elevation. */
|
|
34
|
+
const GRADIENT_ANGLE = '135deg';
|
|
35
|
+
|
|
36
|
+
interface Hsl {
|
|
37
|
+
h: number;
|
|
38
|
+
s: number;
|
|
39
|
+
l: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `[r, g, b]` in 0–255 from any CSS colour MUI can parse, or `null`. */
|
|
43
|
+
function toRgb(color: string): [number, number, number] | null {
|
|
44
|
+
try {
|
|
45
|
+
const { values } = decomposeColor(color);
|
|
46
|
+
const [r, g, b] = values;
|
|
47
|
+
if (r === undefined || g === undefined || b === undefined) return null;
|
|
48
|
+
return [r, g, b];
|
|
49
|
+
} catch {
|
|
50
|
+
// A CSS variable, a colour name MUI does not know, a typo in a tenant's
|
|
51
|
+
// config. The caller falls back to the theme rather than painting nothing.
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rgbToHsl([r255, g255, b255]: [number, number, number]): Hsl {
|
|
57
|
+
const [r, g, b] = [r255 / 255, g255 / 255, b255 / 255];
|
|
58
|
+
const max = Math.max(r, g, b);
|
|
59
|
+
const min = Math.min(r, g, b);
|
|
60
|
+
const l = (max + min) / 2;
|
|
61
|
+
const delta = max - min;
|
|
62
|
+
if (delta === 0) return { h: 0, s: 0, l };
|
|
63
|
+
const s = delta / (1 - Math.abs(2 * l - 1));
|
|
64
|
+
return { h: hueOf(r, g, b, max, delta), s, l };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Split out so {@link rgbToHsl} stays inside the complexity bar. */
|
|
68
|
+
function hueOf(r: number, g: number, b: number, max: number, delta: number): number {
|
|
69
|
+
let h: number;
|
|
70
|
+
if (max === r) h = 60 * (((g - b) / delta) % 6);
|
|
71
|
+
else if (max === g) h = 60 * ((b - r) / delta + 2);
|
|
72
|
+
else h = 60 * ((r - g) / delta + 4);
|
|
73
|
+
return (h + 360) % 360;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hslToHex({ h, s, l }: Hsl): string {
|
|
77
|
+
const a = s * Math.min(l, 1 - l);
|
|
78
|
+
const channel = (n: number): string => {
|
|
79
|
+
const k = (n + h / 30) % 12;
|
|
80
|
+
const value = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
|
|
81
|
+
return Math.round(value * 255)
|
|
82
|
+
.toString(16)
|
|
83
|
+
.padStart(2, '0');
|
|
84
|
+
};
|
|
85
|
+
return `#${channel(0)}${channel(8)}${channel(4)}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The seed's companion stop: rotated toward the warm side and lightened, or —
|
|
92
|
+
* for a seed with no hue worth rotating — lightened alone.
|
|
93
|
+
*/
|
|
94
|
+
export function highlightOf(seed: string): string {
|
|
95
|
+
const rgb = toRgb(seed);
|
|
96
|
+
if (!rgb) return seed;
|
|
97
|
+
const { h, s, l } = rgbToHsl(rgb);
|
|
98
|
+
const lightened = clamp01(l + HIGHLIGHT_LIGHTEN);
|
|
99
|
+
if (s < HUELESS) return hslToHex({ h, s, l: lightened });
|
|
100
|
+
return hslToHex({ h: (h + HIGHLIGHT_HUE_SHIFT) % 360, s, l: lightened });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The mark's fill: the seed, running into its derived highlight. */
|
|
104
|
+
export function brandGradient(seed: string): string {
|
|
105
|
+
return `linear-gradient(${GRADIENT_ANGLE}, ${seed} 0%, ${highlightOf(seed)} 100%)`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Up to two initials for `name`, or `·` when there is nothing to take.
|
|
110
|
+
*
|
|
111
|
+
* First + last rather than first two: "Future Drink" is FD, and a one-word name
|
|
112
|
+
* still yields its single letter instead of a pair that reads as two words.
|
|
113
|
+
*/
|
|
114
|
+
export function initialsOf(name: string): string {
|
|
115
|
+
const parts = name.trim().split(/\s+/u).filter(Boolean);
|
|
116
|
+
if (parts.length === 0) return '·';
|
|
117
|
+
const first = parts[0]?.[0] ?? '';
|
|
118
|
+
const last = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? '') : '';
|
|
119
|
+
return (first + last).toUpperCase();
|
|
120
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Typography, useMediaQuery, useTheme } from '@mui/material';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { accentFor } from '../../../tokens/scales';
|
|
7
|
+
import { Sheet } from '../../data-display/Sheet/Sheet';
|
|
8
|
+
import { Button } from '../../form/Button/Button';
|
|
9
|
+
import { Dialog } from '../../feedback/Dialog/Dialog';
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
AppHeaderDetailRow,
|
|
13
|
+
AppHeaderDetailsAction,
|
|
14
|
+
AppHeaderDetailsProps,
|
|
15
|
+
} from './AppHeader.types';
|
|
16
|
+
|
|
17
|
+
/** How wide the dialog presentation grows before it stops. */
|
|
18
|
+
const DIALOG_WIDTH = 460;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Where the smallest phones stop. The theme's scale starts at `xs`/`sm`, so
|
|
22
|
+
* there is no `xxs` breakpoint to ask for — this is that boundary, named once
|
|
23
|
+
* rather than inlined at the place it decides something.
|
|
24
|
+
*/
|
|
25
|
+
const XXS_MAX_WIDTH = 360;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The sheet grows to its content and stops at the viewport; it does not take a
|
|
29
|
+
* fixed slab of height.
|
|
30
|
+
*
|
|
31
|
+
* `Sheet`'s vertical presets are absolute (`md` is 400px), which on a phone is
|
|
32
|
+
* shorter than six detail rows plus a button — so the panel scrolled INSIDE a
|
|
33
|
+
* screen that had room to spare, putting the address and the way out below the
|
|
34
|
+
* fold of a sheet using two thirds of the display. `height: auto` sizes it to
|
|
35
|
+
* what is in it, and the cap keeps a long panel on-screen: 100dvh on the
|
|
36
|
+
* smallest phones where every pixel counts, 90dvh above that so the panel still
|
|
37
|
+
* reads as a sheet over a page rather than as a new screen.
|
|
38
|
+
*
|
|
39
|
+
* `dvh` and not `vh` — mobile browser chrome makes `vh` overshoot, which would
|
|
40
|
+
* reintroduce exactly the scroll this removes.
|
|
41
|
+
*/
|
|
42
|
+
function sheetHeight(xxs: boolean): { height: string; maxHeight: string } {
|
|
43
|
+
return { height: 'auto', maxHeight: xxs ? '100dvh' : '90dvh' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Which surface this viewport calls for. Lifted out to keep the component lean. */
|
|
47
|
+
function usesSheet(presentation: AppHeaderDetailsProps['presentation'], compact: boolean): boolean {
|
|
48
|
+
if (presentation === 'sheet') return true;
|
|
49
|
+
if (presentation === 'dialog') return false;
|
|
50
|
+
return compact;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One label/value line. The value may wrap to two lines; the label may not. */
|
|
54
|
+
const DetailRow: React.FC<{ row: AppHeaderDetailRow; last: boolean; dataTestId: string }> = ({
|
|
55
|
+
row,
|
|
56
|
+
last,
|
|
57
|
+
dataTestId,
|
|
58
|
+
}) => {
|
|
59
|
+
const theme = useTheme();
|
|
60
|
+
return (
|
|
61
|
+
<Box
|
|
62
|
+
data-testid={`${dataTestId}-row`}
|
|
63
|
+
data-row={row.id ?? row.label}
|
|
64
|
+
sx={{
|
|
65
|
+
display: 'flex',
|
|
66
|
+
alignItems: 'flex-start',
|
|
67
|
+
justifyContent: 'space-between',
|
|
68
|
+
gap: 2,
|
|
69
|
+
py: 1.5,
|
|
70
|
+
borderBottom: last ? 'none' : `1px solid ${theme.palette.divider}`,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<Typography variant="body1" color="text.secondary" component="span" sx={{ flex: '0 0 auto' }}>
|
|
74
|
+
{row.label}
|
|
75
|
+
</Typography>
|
|
76
|
+
<Typography
|
|
77
|
+
variant="body1"
|
|
78
|
+
component="span"
|
|
79
|
+
sx={{
|
|
80
|
+
minWidth: 0,
|
|
81
|
+
textAlign: 'right',
|
|
82
|
+
fontWeight: row.tone ? 700 : 500,
|
|
83
|
+
color: row.tone ? accentFor(theme, row.tone).main : 'text.primary',
|
|
84
|
+
// Newlines in a value are meaningful — an address is two lines.
|
|
85
|
+
whiteSpace: 'pre-line',
|
|
86
|
+
}}
|
|
87
|
+
>
|
|
88
|
+
{row.value}
|
|
89
|
+
</Typography>
|
|
90
|
+
</Box>
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The rows plus whatever the caller put under them. */
|
|
95
|
+
const DetailsBody: React.FC<
|
|
96
|
+
Pick<AppHeaderDetailsProps, 'rows' | 'children'> & { dataTestId: string }
|
|
97
|
+
> = ({ rows, children, dataTestId }) => (
|
|
98
|
+
<Box data-testid={`${dataTestId}-body`}>
|
|
99
|
+
{(rows ?? []).map((row, index) => (
|
|
100
|
+
<DetailRow
|
|
101
|
+
key={row.id ?? row.label}
|
|
102
|
+
row={row}
|
|
103
|
+
last={index === (rows?.length ?? 0) - 1 && !children}
|
|
104
|
+
dataTestId={dataTestId}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
{children}
|
|
108
|
+
</Box>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The single full-width call to action, or nothing.
|
|
113
|
+
*
|
|
114
|
+
* `padded` is for the dialog, which has no footer slot of its own — and the
|
|
115
|
+
* padding lives here rather than at the call site so an action-less panel does
|
|
116
|
+
* not end in a strip of empty space.
|
|
117
|
+
*/
|
|
118
|
+
const DetailsAction: React.FC<{
|
|
119
|
+
action?: AppHeaderDetailsAction;
|
|
120
|
+
dataTestId: string;
|
|
121
|
+
padded?: boolean;
|
|
122
|
+
}> = ({ action, dataTestId, padded = false }) =>
|
|
123
|
+
action ? (
|
|
124
|
+
<Box sx={padded ? { pt: 2 } : undefined}>
|
|
125
|
+
<Button
|
|
126
|
+
variant="solid"
|
|
127
|
+
color={action.color ?? 'primary'}
|
|
128
|
+
size="lg"
|
|
129
|
+
fullWidth
|
|
130
|
+
disabled={action.disabled}
|
|
131
|
+
onClick={action.onClick}
|
|
132
|
+
dataTestId={action.dataTestId ?? `${dataTestId}-action`}
|
|
133
|
+
>
|
|
134
|
+
{action.label}
|
|
135
|
+
</Button>
|
|
136
|
+
</Box>
|
|
137
|
+
) : null;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The panel behind the header's disclosure — the hours, the address, the way
|
|
141
|
+
* out — in the shape the viewport calls for.
|
|
142
|
+
*
|
|
143
|
+
* ONE component with two presentations, because they are one thing to the user
|
|
144
|
+
* and one thing to the caller: a bottom sheet within reach of a thumb, a centred
|
|
145
|
+
* dialog under a pointer. `auto` picks by viewport, so nobody writes that branch
|
|
146
|
+
* at a call site and nobody ships a phone-shaped sheet stretched across a
|
|
147
|
+
* desktop. Force either one with `presentation` when the surrounding layout
|
|
148
|
+
* already decides (a preview frame, a story, a kiosk).
|
|
149
|
+
*/
|
|
150
|
+
export const AppHeaderDetails: React.FC<AppHeaderDetailsProps> = ({
|
|
151
|
+
open,
|
|
152
|
+
onClose,
|
|
153
|
+
title,
|
|
154
|
+
subtitle,
|
|
155
|
+
rows,
|
|
156
|
+
children,
|
|
157
|
+
action,
|
|
158
|
+
presentation = 'auto',
|
|
159
|
+
breakpoint = 'sm',
|
|
160
|
+
className,
|
|
161
|
+
dataTestId = 'app-header-details',
|
|
162
|
+
}) => {
|
|
163
|
+
const theme = useTheme();
|
|
164
|
+
const asSheet = usesSheet(presentation, useMediaQuery(theme.breakpoints.down(breakpoint)));
|
|
165
|
+
const xxs = useMediaQuery(`(max-width:${XXS_MAX_WIDTH - 0.05}px)`);
|
|
166
|
+
|
|
167
|
+
// Split once, not per branch: a STRING subtitle is the panel's own
|
|
168
|
+
// `description` (both surfaces style it as part of their header), anything
|
|
169
|
+
// richer is a node we render ourselves above the rows.
|
|
170
|
+
const description = typeof subtitle === 'string' ? subtitle : undefined;
|
|
171
|
+
const richSubtitle = description === undefined ? subtitle : null;
|
|
172
|
+
|
|
173
|
+
const body = (
|
|
174
|
+
<DetailsBody rows={rows} dataTestId={dataTestId}>
|
|
175
|
+
{children}
|
|
176
|
+
</DetailsBody>
|
|
177
|
+
);
|
|
178
|
+
const footer = <DetailsAction action={action} dataTestId={dataTestId} />;
|
|
179
|
+
|
|
180
|
+
if (asSheet) {
|
|
181
|
+
// No `showHandle`. The grab bar promises a drag this panel does not answer:
|
|
182
|
+
// its height is its content's, so there is no second position to drag it
|
|
183
|
+
// to, and the handle sat above a ✕ that already closes it. Swipe-to-dismiss
|
|
184
|
+
// is `Sheet`'s default and still works — it just stops being advertised by
|
|
185
|
+
// a control that looked resizable and was not.
|
|
186
|
+
return (
|
|
187
|
+
<Sheet
|
|
188
|
+
open={open}
|
|
189
|
+
onClose={onClose}
|
|
190
|
+
onOpenChange={(next) => !next && onClose()}
|
|
191
|
+
position="bottom"
|
|
192
|
+
size="md"
|
|
193
|
+
style={sheetHeight(xxs)}
|
|
194
|
+
className={className}
|
|
195
|
+
title={title}
|
|
196
|
+
description={description}
|
|
197
|
+
showCloseButton
|
|
198
|
+
rounded
|
|
199
|
+
footer={footer}
|
|
200
|
+
dataTestId={dataTestId}
|
|
201
|
+
>
|
|
202
|
+
{richSubtitle}
|
|
203
|
+
{body}
|
|
204
|
+
</Sheet>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return (
|
|
209
|
+
<Dialog
|
|
210
|
+
open={open}
|
|
211
|
+
onClose={onClose}
|
|
212
|
+
title={title}
|
|
213
|
+
description={description}
|
|
214
|
+
showCloseButton
|
|
215
|
+
fullWidth
|
|
216
|
+
className={className}
|
|
217
|
+
dataTestId={dataTestId}
|
|
218
|
+
PaperProps={{ sx: { maxWidth: DIALOG_WIDTH, borderRadius: 3 } }}
|
|
219
|
+
>
|
|
220
|
+
{richSubtitle}
|
|
221
|
+
{body}
|
|
222
|
+
<DetailsAction action={action} dataTestId={dataTestId} padded />
|
|
223
|
+
</Dialog>
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
AppHeaderDetails.displayName = 'AppHeaderDetails';
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import KeyboardArrowDown from '@mui/icons-material/KeyboardArrowDown';
|
|
4
|
+
import { Box, Skeleton, Typography, useTheme } from '@mui/material';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
import { accentFor, type SizeValue } from '../../../tokens/scales';
|
|
8
|
+
|
|
9
|
+
import { AppHeaderBrand } from './AppHeader.brand';
|
|
10
|
+
import type { AppHeaderIdentityProps, AppHeaderStatusProps } from './AppHeader.types';
|
|
11
|
+
|
|
12
|
+
/** Title type scale, per house size step. */
|
|
13
|
+
const TITLE_PX: Record<SizeValue, number> = { xs: 14, sm: 15, md: 17, lg: 20, xl: 24 };
|
|
14
|
+
|
|
15
|
+
/** One line, ellipsised — a long store name must not push the actions off-screen. */
|
|
16
|
+
const ONE_LINE = {
|
|
17
|
+
minWidth: 0,
|
|
18
|
+
overflow: 'hidden',
|
|
19
|
+
textOverflow: 'ellipsis',
|
|
20
|
+
whiteSpace: 'nowrap',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The dotted state line: "● Aberto agora · Retirada no balcão".
|
|
25
|
+
*
|
|
26
|
+
* Segments are joined here rather than by the caller because the separator is a
|
|
27
|
+
* presentational choice — a caller that pre-joins its own string can never be
|
|
28
|
+
* restyled, and each segment loses the chance to carry its own markup.
|
|
29
|
+
*/
|
|
30
|
+
export const AppHeaderStatus: React.FC<AppHeaderStatusProps> = ({
|
|
31
|
+
tone,
|
|
32
|
+
items,
|
|
33
|
+
separator = '·',
|
|
34
|
+
className,
|
|
35
|
+
dataTestId = 'app-header-status',
|
|
36
|
+
}) => {
|
|
37
|
+
const theme = useTheme();
|
|
38
|
+
const visible = items.filter((item) => item !== null && item !== undefined && item !== '');
|
|
39
|
+
if (visible.length === 0) return null;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box
|
|
43
|
+
className={className}
|
|
44
|
+
data-testid={dataTestId}
|
|
45
|
+
sx={{ display: 'flex', alignItems: 'center', gap: 0.75, ...ONE_LINE }}
|
|
46
|
+
>
|
|
47
|
+
{tone && (
|
|
48
|
+
<Box
|
|
49
|
+
aria-hidden
|
|
50
|
+
data-testid={`${dataTestId}-dot`}
|
|
51
|
+
sx={{
|
|
52
|
+
width: 8,
|
|
53
|
+
height: 8,
|
|
54
|
+
flex: '0 0 auto',
|
|
55
|
+
borderRadius: '50%',
|
|
56
|
+
backgroundColor: accentFor(theme, tone).main,
|
|
57
|
+
}}
|
|
58
|
+
/>
|
|
59
|
+
)}
|
|
60
|
+
<Typography variant="body2" color="text.secondary" component="span" sx={ONE_LINE}>
|
|
61
|
+
{visible.map((item, index) => (
|
|
62
|
+
// Index keys: the segments are positional presentation, and nothing
|
|
63
|
+
// here reorders or is keyed on identity elsewhere.
|
|
64
|
+
<React.Fragment key={index}>
|
|
65
|
+
{index > 0 && (
|
|
66
|
+
<>
|
|
67
|
+
{/* Real spaces, not margin. A screen reader reads `textContent`,
|
|
68
|
+
and a CSS-only gap runs "Aberto agora" straight into
|
|
69
|
+
"Retirada no balcão" as one word. The glyph itself is
|
|
70
|
+
hidden — it is punctuation, not something to announce. */}
|
|
71
|
+
{' '}
|
|
72
|
+
<Box component="span" aria-hidden>
|
|
73
|
+
{separator}
|
|
74
|
+
</Box>{' '}
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
{item}
|
|
78
|
+
</React.Fragment>
|
|
79
|
+
))}
|
|
80
|
+
</Typography>
|
|
81
|
+
</Box>
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
AppHeaderStatus.displayName = 'AppHeaderStatus';
|
|
86
|
+
|
|
87
|
+
/** Title + chevron + the quieter lines under them. */
|
|
88
|
+
const IdentityText: React.FC<
|
|
89
|
+
Pick<AppHeaderIdentityProps, 'title' | 'subtitle' | 'status' | 'size'> & {
|
|
90
|
+
interactive: boolean;
|
|
91
|
+
disclosed: boolean;
|
|
92
|
+
dataTestId: string;
|
|
93
|
+
}
|
|
94
|
+
> = ({ title, subtitle, status, size = 'md', interactive, disclosed, dataTestId }) => (
|
|
95
|
+
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 0.25 }}>
|
|
96
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25, minWidth: 0 }}>
|
|
97
|
+
<Typography
|
|
98
|
+
component="span"
|
|
99
|
+
data-testid={`${dataTestId}-title`}
|
|
100
|
+
sx={{ fontSize: TITLE_PX[size], fontWeight: 700, lineHeight: 1.2, ...ONE_LINE }}
|
|
101
|
+
>
|
|
102
|
+
{title}
|
|
103
|
+
</Typography>
|
|
104
|
+
{interactive && (
|
|
105
|
+
<KeyboardArrowDown
|
|
106
|
+
aria-hidden
|
|
107
|
+
data-testid={`${dataTestId}-chevron`}
|
|
108
|
+
sx={{
|
|
109
|
+
flex: '0 0 auto',
|
|
110
|
+
fontSize: 20,
|
|
111
|
+
opacity: 0.7,
|
|
112
|
+
transition: 'transform 150ms ease',
|
|
113
|
+
transform: disclosed ? 'rotate(180deg)' : 'none',
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
</Box>
|
|
118
|
+
{subtitle && (
|
|
119
|
+
<Typography variant="body2" color="text.secondary" component="span" sx={ONE_LINE}>
|
|
120
|
+
{subtitle}
|
|
121
|
+
</Typography>
|
|
122
|
+
)}
|
|
123
|
+
{status}
|
|
124
|
+
</Box>
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/** The placeholder held while the identity is still resolving. */
|
|
128
|
+
const IdentitySkeleton: React.FC<{ size: SizeValue; dataTestId: string }> = ({
|
|
129
|
+
size,
|
|
130
|
+
dataTestId,
|
|
131
|
+
}) => (
|
|
132
|
+
<Box
|
|
133
|
+
data-testid={`${dataTestId}-loading`}
|
|
134
|
+
sx={{ display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }}
|
|
135
|
+
>
|
|
136
|
+
<Skeleton variant="rounded" width={40} height={40} sx={{ flex: '0 0 auto' }} />
|
|
137
|
+
<Box sx={{ minWidth: 0, flex: 1 }}>
|
|
138
|
+
<Skeleton variant="text" width={140} height={TITLE_PX[size] * 1.4} />
|
|
139
|
+
<Skeleton variant="text" width={180} height={16} />
|
|
140
|
+
</Box>
|
|
141
|
+
</Box>
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Row styling shared by the static and the button rendering.
|
|
146
|
+
*
|
|
147
|
+
* `width: '100%'` is load-bearing, not cosmetic. A `<button>` sizes to
|
|
148
|
+
* max-content and — unlike the `div` the non-interactive path renders — does
|
|
149
|
+
* not stretch to fill its block container, so without it the DISCLOSING
|
|
150
|
+
* identity measured its widest possible line at every viewport: the row's
|
|
151
|
+
* right edge stayed pinned at 564px while the bar narrowed, `ONE_LINE` never
|
|
152
|
+
* had a constraint to ellipsise against, and the title and state line ran
|
|
153
|
+
* straight under the actions — 327px of overlap at 320px wide. Filling the
|
|
154
|
+
* wrapper is what lets `minWidth: 0` bite and the ellipsis engage.
|
|
155
|
+
*/
|
|
156
|
+
const rowSx = {
|
|
157
|
+
display: 'flex',
|
|
158
|
+
alignItems: 'center',
|
|
159
|
+
gap: 1.25,
|
|
160
|
+
width: '100%',
|
|
161
|
+
minWidth: 0,
|
|
162
|
+
textAlign: 'left',
|
|
163
|
+
} as const;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Mark + title + state line, and — when it can be opened — the whole block as
|
|
167
|
+
* one button.
|
|
168
|
+
*
|
|
169
|
+
* The button wraps EVERYTHING rather than sitting next to the title, because on
|
|
170
|
+
* a phone the chevron alone is a 20px target next to a 40px one that does
|
|
171
|
+
* nothing. A shopper aiming at the store's name expects the store's details.
|
|
172
|
+
*/
|
|
173
|
+
export const AppHeaderIdentity: React.FC<AppHeaderIdentityProps> = ({
|
|
174
|
+
title,
|
|
175
|
+
subtitle,
|
|
176
|
+
status,
|
|
177
|
+
mark,
|
|
178
|
+
logoUrl,
|
|
179
|
+
seedColor,
|
|
180
|
+
onDisclose,
|
|
181
|
+
disclosed = false,
|
|
182
|
+
discloseLabel,
|
|
183
|
+
loading = false,
|
|
184
|
+
size = 'md',
|
|
185
|
+
className,
|
|
186
|
+
dataTestId = 'app-header-identity',
|
|
187
|
+
}) => {
|
|
188
|
+
if (loading) return <IdentitySkeleton size={size} dataTestId={dataTestId} />;
|
|
189
|
+
|
|
190
|
+
const body = (
|
|
191
|
+
<>
|
|
192
|
+
{mark ?? (
|
|
193
|
+
<AppHeaderBrand
|
|
194
|
+
name={title}
|
|
195
|
+
logoUrl={logoUrl}
|
|
196
|
+
seedColor={seedColor}
|
|
197
|
+
size={size}
|
|
198
|
+
dataTestId={`${dataTestId}-brand`}
|
|
199
|
+
/>
|
|
200
|
+
)}
|
|
201
|
+
<IdentityText
|
|
202
|
+
title={title}
|
|
203
|
+
subtitle={subtitle}
|
|
204
|
+
status={status}
|
|
205
|
+
size={size}
|
|
206
|
+
interactive={Boolean(onDisclose)}
|
|
207
|
+
disclosed={disclosed}
|
|
208
|
+
dataTestId={dataTestId}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
if (!onDisclose) {
|
|
214
|
+
return (
|
|
215
|
+
<Box className={className} data-testid={dataTestId} sx={rowSx}>
|
|
216
|
+
{body}
|
|
217
|
+
</Box>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Box
|
|
223
|
+
component="button"
|
|
224
|
+
type="button"
|
|
225
|
+
className={className}
|
|
226
|
+
data-testid={dataTestId}
|
|
227
|
+
onClick={onDisclose}
|
|
228
|
+
aria-haspopup="dialog"
|
|
229
|
+
aria-expanded={disclosed}
|
|
230
|
+
aria-label={discloseLabel ?? `Detalhes de ${title}`}
|
|
231
|
+
sx={{
|
|
232
|
+
...rowSx,
|
|
233
|
+
appearance: 'none',
|
|
234
|
+
p: 0.5,
|
|
235
|
+
ml: -0.5,
|
|
236
|
+
border: 'none',
|
|
237
|
+
borderRadius: 1.5,
|
|
238
|
+
background: 'none',
|
|
239
|
+
color: 'inherit',
|
|
240
|
+
font: 'inherit',
|
|
241
|
+
cursor: 'pointer',
|
|
242
|
+
'&:hover': { backgroundColor: 'action.hover' },
|
|
243
|
+
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 2 },
|
|
244
|
+
}}
|
|
245
|
+
>
|
|
246
|
+
{body}
|
|
247
|
+
</Box>
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
AppHeaderIdentity.displayName = 'AppHeaderIdentity';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# AppHeader
|
|
2
|
+
|
|
3
|
+
The application bar, as a set of slots on one surface — plus the identity block,
|
|
4
|
+
the state line and the disclosure panel that usually fill them.
|
|
5
|
+
|
|
6
|
+
It is deliberately ignorant. There is no router in it, no session, no cart, no
|
|
7
|
+
store: every slot is a `ReactNode` and every behaviour is a callback. That is
|
|
8
|
+
what lets one bar serve a storefront, a back office and a platform console
|
|
9
|
+
without any of the three leaking into the other two.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import {
|
|
13
|
+
AppHeader,
|
|
14
|
+
AppHeaderDetails,
|
|
15
|
+
AppHeaderIdentity,
|
|
16
|
+
AppHeaderStatus,
|
|
17
|
+
} from '@12-apps/ui/navigation/AppHeader';
|
|
18
|
+
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
|
|
21
|
+
<AppHeader
|
|
22
|
+
meta="Build 18"
|
|
23
|
+
actions={<Button onClick={signIn}>Entrar</Button>}
|
|
24
|
+
below={<SearchField />}
|
|
25
|
+
>
|
|
26
|
+
<AppHeaderIdentity
|
|
27
|
+
title={store.name}
|
|
28
|
+
logoUrl={store.logoUrl}
|
|
29
|
+
seedColor={store.primaryColor}
|
|
30
|
+
status={<AppHeaderStatus tone="success" items={['Aberto agora', 'Retirada no balcão']} />}
|
|
31
|
+
disclosed={open}
|
|
32
|
+
onDisclose={() => setOpen(true)}
|
|
33
|
+
/>
|
|
34
|
+
</AppHeader>
|
|
35
|
+
|
|
36
|
+
<AppHeaderDetails
|
|
37
|
+
open={open}
|
|
38
|
+
onClose={() => setOpen(false)}
|
|
39
|
+
title={store.name}
|
|
40
|
+
subtitle={store.tagline}
|
|
41
|
+
rows={[
|
|
42
|
+
{ label: 'Agora', value: 'Aberto até 22h', tone: 'success' },
|
|
43
|
+
{ label: 'Endereço', value: `${store.street}\n${store.district}` },
|
|
44
|
+
]}
|
|
45
|
+
action={{ label: 'Trocar de loja', onClick: goToStorePicker }}
|
|
46
|
+
/>;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## The parts
|
|
50
|
+
|
|
51
|
+
| Component | What it is |
|
|
52
|
+
| -------------------- | ---------------------------------------------------------------- |
|
|
53
|
+
| `AppHeader` | The bar: `leading`, the identity (children), `actions`, `meta`, `below` |
|
|
54
|
+
| `AppHeaderIdentity` | Mark + title + state line, and the disclosure when it can open one |
|
|
55
|
+
| `AppHeaderBrand` | The mark alone — a logo, or initials on a gradient derived from one colour |
|
|
56
|
+
| `AppHeaderStatus` | The dotted state line: `● Aberto agora · Retirada no balcão` |
|
|
57
|
+
| `AppHeaderDetails` | The panel behind the disclosure — a sheet on phones, a dialog on large screens |
|
|
58
|
+
|
|
59
|
+
Each is exported on its own, so a bar that wants only the mark, or only the
|
|
60
|
+
state line, takes just that.
|
|
61
|
+
|
|
62
|
+
## One bar, every width
|
|
63
|
+
|
|
64
|
+
The SURFACE always spans the viewport; the CONTENT stops at `maxWidth` (1200 by
|
|
65
|
+
default) and centres. So the same element reads as a phone header and as a
|
|
66
|
+
desktop one, with no breakpoint at the call site and no second component.
|
|
67
|
+
|
|
68
|
+
`AppHeaderDetails` follows the same rule one level down. It is one component
|
|
69
|
+
with two presentations because it is one thing to the user — a bottom sheet
|
|
70
|
+
within reach of a thumb, a centred dialog under a pointer — and `presentation:
|
|
71
|
+
'auto'` (the default) picks by viewport. Force it with `'sheet'` or `'dialog'`
|
|
72
|
+
when the surrounding layout has already decided: a preview frame, a kiosk, a
|
|
73
|
+
story.
|
|
74
|
+
|
|
75
|
+
The sheet is **as tall as what is in it**, capped at the viewport — `100dvh`
|
|
76
|
+
below 360px, `90dvh` above it. It does not take a fixed slab of height:
|
|
77
|
+
`Sheet`'s vertical presets are absolute (`md` is 400px), which is shorter than
|
|
78
|
+
six rows plus a button, so the panel scrolled INSIDE a screen that had room to
|
|
79
|
+
spare and left the address and the way out below its own fold. `dvh` and not
|
|
80
|
+
`vh`, because mobile browser chrome makes `vh` overshoot and would put that
|
|
81
|
+
scroll straight back.
|
|
82
|
+
|
|
83
|
+
It carries no grab handle either. There is no second position to drag the panel
|
|
84
|
+
to now that its height is its content's, so the handle promised a resize it
|
|
85
|
+
could not answer while sitting directly above a ✕ that already closes it.
|
|
86
|
+
Swipe-to-dismiss is `Sheet`'s default and still works.
|
|
87
|
+
|
|
88
|
+
## One brand colour is enough
|
|
89
|
+
|
|
90
|
+
`seedColor` takes a single hex and the mark derives its own gradient from it, by
|
|
91
|
+
rotating the seed's hue toward the warm side and lifting its lightness. With no
|
|
92
|
+
seed it derives from the theme's primary instead, so an app with no per-tenant
|
|
93
|
+
colour needs to pass nothing.
|
|
94
|
+
|
|
95
|
+
The alternative — a second "highlight" prop — asks every caller to pick a colour
|
|
96
|
+
that pairs with a colour they already picked, and in a multi-tenant app it asks
|
|
97
|
+
that of people who will never see this component. A seed with no hue worth
|
|
98
|
+
rotating (a grey, a black) stays grey: a hueless brand is not given a colour it
|
|
99
|
+
never chose. A seed the browser cannot parse is returned untouched rather than
|
|
100
|
+
throwing.
|
|
101
|
+
|
|
102
|
+
## What it will not do
|
|
103
|
+
|
|
104
|
+
- **Navigate.** There is no `href` on the identity. An `href` would have to guess
|
|
105
|
+
whose link component it is being handed — `to` for one router, `href` for
|
|
106
|
+
another — and would half-work for everybody. A title that navigates is one line
|
|
107
|
+
of your own router in the `leading` slot.
|
|
108
|
+
- **Fetch anything.** `loading` on the identity holds a skeleton the size of the
|
|
109
|
+
real block; you decide when that is true. It exists because the obvious
|
|
110
|
+
fallback is actively wrong on a white-label storefront: painting a placeholder
|
|
111
|
+
name flashes the platform's brand for a frame on every load, on a page a
|
|
112
|
+
merchant pays for it not to appear on.
|
|
113
|
+
- **Own its own open state.** `AppHeaderDetails` is controlled. The disclosure
|
|
114
|
+
and the panel are separate components precisely so the panel can live wherever
|
|
115
|
+
the layout needs it.
|
|
116
|
+
|
|
117
|
+
## Accessibility
|
|
118
|
+
|
|
119
|
+
- The mark is `role="img"` labelled with the name, so a screen reader says
|
|
120
|
+
"Future Drink" rather than spelling out "F D".
|
|
121
|
+
- With `onDisclose`, the whole identity block is one `<button>` with
|
|
122
|
+
`aria-haspopup="dialog"` and a live `aria-expanded`. The button wraps
|
|
123
|
+
everything rather than sitting beside the title: on a phone the chevron alone
|
|
124
|
+
is a 20px target next to a 40px one that does nothing, and a shopper aiming at
|
|
125
|
+
the store's name expects the store's details.
|
|
126
|
+
- The state line separates its segments with real whitespace, not CSS margin —
|
|
127
|
+
a screen reader reads `textContent`, and a margin-only gap runs "Aberto agora"
|
|
128
|
+
straight into "Retirada no balcão" as one word. The separator glyph itself is
|
|
129
|
+
`aria-hidden`: it is punctuation, not something to announce.
|
|
130
|
+
- A `fixed` bar renders a spacer of its own MEASURED height. A constant cannot
|
|
131
|
+
do it — the height is whatever your slots add up to, and a search field in
|
|
132
|
+
`below` moves it — so the bar observes itself and republishes. Where
|
|
133
|
+
`ResizeObserver` does not exist (jsdom, so: your tests) it measures once
|
|
134
|
+
instead of throwing.
|
|
135
|
+
|
|
136
|
+
## Props
|
|
137
|
+
|
|
138
|
+
See `AppHeader.types.ts`. Every prop is documented at its declaration.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box } from '@mui/material';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
import { withDefaults } from '../../../utils/withDefaults';
|
|
7
|
+
|
|
8
|
+
import type { AppHeaderProps } from './AppHeader.types';
|
|
9
|
+
|
|
10
|
+
/** Bar height, before the optional `below` row. */
|
|
11
|
+
const BAR_MIN_HEIGHT = { regular: 64, dense: 52 } as const;
|
|
12
|
+
|
|
13
|
+
/** The props {@link HEADER_DEFAULTS} guarantees a value for. */
|
|
14
|
+
type Defaulted =
|
|
15
|
+
| 'position'
|
|
16
|
+
| 'offsetTop'
|
|
17
|
+
| 'maxWidth'
|
|
18
|
+
| 'divider'
|
|
19
|
+
| 'elevateOnScroll'
|
|
20
|
+
| 'dense'
|
|
21
|
+
| 'disableSpacer'
|
|
22
|
+
| 'dataTestId';
|
|
23
|
+
|
|
24
|
+
/** `AppHeaderProps` as the body sees them: nothing defaulted is still optional. */
|
|
25
|
+
type ResolvedHeaderProps = AppHeaderProps & Required<Pick<AppHeaderProps, Defaulted>>;
|
|
26
|
+
|
|
27
|
+
const HEADER_DEFAULTS: Pick<ResolvedHeaderProps, Defaulted> = {
|
|
28
|
+
position: 'sticky',
|
|
29
|
+
offsetTop: 0,
|
|
30
|
+
maxWidth: 1200,
|
|
31
|
+
divider: true,
|
|
32
|
+
elevateOnScroll: false,
|
|
33
|
+
dense: false,
|
|
34
|
+
disableSpacer: false,
|
|
35
|
+
dataTestId: 'app-header',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Watch the document scroll so a lifted bar can cast a shadow only once there
|
|
40
|
+
* is something under it. Inert (and never attaches a listener) when off.
|
|
41
|
+
*/
|
|
42
|
+
function useScrolled(enabled: boolean): boolean {
|
|
43
|
+
const [scrolled, setScrolled] = React.useState(false);
|
|
44
|
+
React.useEffect(() => {
|
|
45
|
+
if (!enabled) return undefined;
|
|
46
|
+
const onScroll = (): void => setScrolled(window.scrollY > 0);
|
|
47
|
+
onScroll();
|
|
48
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
49
|
+
return () => window.removeEventListener('scroll', onScroll);
|
|
50
|
+
}, [enabled]);
|
|
51
|
+
return enabled && scrolled;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The bar's own height, republished whenever it changes.
|
|
56
|
+
*
|
|
57
|
+
* A `fixed` bar is out of flow, so something has to reserve its space — and a
|
|
58
|
+
* constant cannot, because this bar's height is whatever the caller's slots add
|
|
59
|
+
* up to. A search field in `below`, a status line, a wrapped title: each moves
|
|
60
|
+
* it, and a hard-coded spacer would leave the page's first element under the
|
|
61
|
+
* bar or floating below a gap.
|
|
62
|
+
*/
|
|
63
|
+
function useMeasuredHeight(enabled: boolean): [React.RefObject<HTMLElement | null>, number] {
|
|
64
|
+
const ref = React.useRef<HTMLElement | null>(null);
|
|
65
|
+
const [height, setHeight] = React.useState(0);
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
const node = ref.current;
|
|
68
|
+
if (!enabled || !node) return undefined;
|
|
69
|
+
const measure = (): void => setHeight(node.getBoundingClientRect().height);
|
|
70
|
+
measure();
|
|
71
|
+
// Measured once unconditionally, observed only where the API exists. jsdom
|
|
72
|
+
// has no ResizeObserver, so constructing it unguarded takes down every
|
|
73
|
+
// consumer's test that happens to render a fixed bar — a crash in THEIR
|
|
74
|
+
// suite, thrown from a component they only laid out.
|
|
75
|
+
if (typeof ResizeObserver === 'undefined') return undefined;
|
|
76
|
+
const observer = new ResizeObserver(measure);
|
|
77
|
+
observer.observe(node);
|
|
78
|
+
return () => observer.disconnect();
|
|
79
|
+
}, [enabled]);
|
|
80
|
+
return [ref, height];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The bar's own surface. Lifted out of the component both to keep it inside the
|
|
85
|
+
* complexity bar and because the positioning rules are the fiddly part: a
|
|
86
|
+
* `static` bar must claim neither a stacking context nor an offset, and only a
|
|
87
|
+
* `fixed` one needs pinning to the viewport's edges.
|
|
88
|
+
*/
|
|
89
|
+
function surfaceSx({
|
|
90
|
+
position,
|
|
91
|
+
offsetTop,
|
|
92
|
+
divider,
|
|
93
|
+
scrolled,
|
|
94
|
+
}: Required<Pick<AppHeaderProps, 'position' | 'offsetTop' | 'divider'>> & {
|
|
95
|
+
scrolled: boolean;
|
|
96
|
+
}): Record<string, unknown> {
|
|
97
|
+
return {
|
|
98
|
+
position,
|
|
99
|
+
...(position === 'static' ? {} : { top: offsetTop, zIndex: 'appBar' }),
|
|
100
|
+
...(position === 'fixed' ? { left: 0, right: 0 } : {}),
|
|
101
|
+
backgroundColor: 'background.paper',
|
|
102
|
+
borderBottom: divider ? 1 : 0,
|
|
103
|
+
borderColor: 'divider',
|
|
104
|
+
boxShadow: scrolled ? 2 : 0,
|
|
105
|
+
transition: 'box-shadow 150ms ease',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The trailing column: the muted note, and the actions under it. */
|
|
110
|
+
const HeaderTrailing: React.FC<Pick<AppHeaderProps, 'meta' | 'actions'>> = ({ meta, actions }) => {
|
|
111
|
+
if (!meta && !actions) return null;
|
|
112
|
+
return (
|
|
113
|
+
<Box
|
|
114
|
+
sx={{
|
|
115
|
+
flex: '0 0 auto',
|
|
116
|
+
display: 'flex',
|
|
117
|
+
flexDirection: 'column',
|
|
118
|
+
alignItems: 'flex-end',
|
|
119
|
+
gap: 0.5,
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{meta && (
|
|
123
|
+
<Box
|
|
124
|
+
data-testid="app-header-meta"
|
|
125
|
+
sx={{
|
|
126
|
+
fontSize: 11,
|
|
127
|
+
fontWeight: 700,
|
|
128
|
+
letterSpacing: '0.08em',
|
|
129
|
+
textTransform: 'uppercase',
|
|
130
|
+
color: 'text.disabled',
|
|
131
|
+
lineHeight: 1,
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
{meta}
|
|
135
|
+
</Box>
|
|
136
|
+
)}
|
|
137
|
+
{actions && (
|
|
138
|
+
<Box
|
|
139
|
+
component="nav"
|
|
140
|
+
data-testid="app-header-actions"
|
|
141
|
+
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
|
142
|
+
>
|
|
143
|
+
{actions}
|
|
144
|
+
</Box>
|
|
145
|
+
)}
|
|
146
|
+
</Box>
|
|
147
|
+
);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The application bar: a row of slots on one surface, plus an optional second
|
|
152
|
+
* row that belongs to it.
|
|
153
|
+
*
|
|
154
|
+
* It knows nothing about routers, sessions, carts or stores — every slot is a
|
|
155
|
+
* `ReactNode` and every behaviour is a callback, which is what lets one bar
|
|
156
|
+
* serve a storefront, a back office and a platform console without any of them
|
|
157
|
+
* leaking into the others. Compose it with {@link AppHeaderIdentity},
|
|
158
|
+
* {@link AppHeaderStatus} and {@link AppHeaderDetails}, or put your own nodes in
|
|
159
|
+
* the slots.
|
|
160
|
+
*
|
|
161
|
+
* The SURFACE always spans the viewport while the CONTENT stops at `maxWidth`
|
|
162
|
+
* and centres, so the same bar reads as a phone header and as a desktop one
|
|
163
|
+
* without a second component or a breakpoint at the call site.
|
|
164
|
+
*/
|
|
165
|
+
export const AppHeader: React.FC<AppHeaderProps> = (props) => {
|
|
166
|
+
// A table of defaults rather than one per destructured prop: each `= default`
|
|
167
|
+
// is a branch, and nine of them put this component over the complexity bar
|
|
168
|
+
// before it rendered anything.
|
|
169
|
+
const {
|
|
170
|
+
leading,
|
|
171
|
+
children,
|
|
172
|
+
actions,
|
|
173
|
+
meta,
|
|
174
|
+
below,
|
|
175
|
+
position,
|
|
176
|
+
offsetTop,
|
|
177
|
+
maxWidth,
|
|
178
|
+
divider,
|
|
179
|
+
elevateOnScroll,
|
|
180
|
+
dense,
|
|
181
|
+
disableSpacer,
|
|
182
|
+
className,
|
|
183
|
+
dataTestId,
|
|
184
|
+
// True by construction — `withDefaults` fills exactly these keys — but not
|
|
185
|
+
// something TS can follow through a `Partial<T>` table.
|
|
186
|
+
} = withDefaults(props, HEADER_DEFAULTS) as ResolvedHeaderProps;
|
|
187
|
+
|
|
188
|
+
const scrolled = useScrolled(elevateOnScroll);
|
|
189
|
+
const needsSpacer = position === 'fixed' && !disableSpacer;
|
|
190
|
+
const [ref, height] = useMeasuredHeight(needsSpacer);
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<>
|
|
194
|
+
<Box
|
|
195
|
+
ref={ref}
|
|
196
|
+
component="header"
|
|
197
|
+
className={className}
|
|
198
|
+
data-testid={dataTestId}
|
|
199
|
+
sx={surfaceSx({ position, offsetTop, divider, scrolled })}
|
|
200
|
+
>
|
|
201
|
+
<Box sx={{ maxWidth, mx: 'auto', px: { xs: 2, md: 3 } }}>
|
|
202
|
+
<Box
|
|
203
|
+
sx={{
|
|
204
|
+
display: 'flex',
|
|
205
|
+
alignItems: 'center',
|
|
206
|
+
gap: { xs: 1, md: 2 },
|
|
207
|
+
minHeight: dense ? BAR_MIN_HEIGHT.dense : BAR_MIN_HEIGHT.regular,
|
|
208
|
+
py: 1,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
{leading}
|
|
212
|
+
{/* The identity takes the slack and is the only part allowed to
|
|
213
|
+
shrink — the actions are targets and the meta note is already
|
|
214
|
+
the smallest thing in the bar. */}
|
|
215
|
+
<Box sx={{ flex: '1 1 auto', minWidth: 0 }}>{children}</Box>
|
|
216
|
+
<HeaderTrailing meta={meta} actions={actions} />
|
|
217
|
+
</Box>
|
|
218
|
+
{below && (
|
|
219
|
+
<Box data-testid={`${dataTestId}-below`} sx={{ pb: 1.5 }}>
|
|
220
|
+
{below}
|
|
221
|
+
</Box>
|
|
222
|
+
)}
|
|
223
|
+
</Box>
|
|
224
|
+
</Box>
|
|
225
|
+
{needsSpacer && <Box aria-hidden data-testid={`${dataTestId}-spacer`} sx={{ height }} />}
|
|
226
|
+
</>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
AppHeader.displayName = 'AppHeader';
|
|
231
|
+
|
|
232
|
+
export default AppHeader;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { Breakpoint } from '@mui/material/styles';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import type { ColorValue, SizeValue } from '../../../tokens/scales';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* How the bar sits in the page.
|
|
8
|
+
*
|
|
9
|
+
* `fixed` lifts it out of flow, so {@link AppHeaderProps.disableSpacer} aside it
|
|
10
|
+
* also renders a spacer of its own measured height — a fixed bar with nothing
|
|
11
|
+
* reserving its space is the classic "first paragraph is under the header" bug.
|
|
12
|
+
*/
|
|
13
|
+
export type AppHeaderPosition = 'static' | 'sticky' | 'fixed';
|
|
14
|
+
|
|
15
|
+
/** Which surface the details panel takes on this viewport. */
|
|
16
|
+
export type AppHeaderDetailsPresentation = 'auto' | 'sheet' | 'dialog';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The circular mark that opens the header: a logo when there is one, the name's
|
|
20
|
+
* initials on a derived gradient when there is not.
|
|
21
|
+
*
|
|
22
|
+
* The gradient is DERIVED from `seedColor` rather than configured, so a caller
|
|
23
|
+
* supplies one brand colour and gets a mark that belongs to it. With no seed it
|
|
24
|
+
* derives from the theme's primary, which is what makes the mark correct in an
|
|
25
|
+
* app that has no per-tenant colour at all.
|
|
26
|
+
*/
|
|
27
|
+
export interface AppHeaderBrandProps {
|
|
28
|
+
/** Who the mark stands for. Initials come from it, and it labels the mark. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Logo image; replaces the initials when it loads. */
|
|
31
|
+
logoUrl?: string | null;
|
|
32
|
+
/** `#RRGGBB` (or any CSS colour) to derive the gradient from. */
|
|
33
|
+
seedColor?: string | null;
|
|
34
|
+
/** @default 'md' */
|
|
35
|
+
size?: SizeValue;
|
|
36
|
+
/** @default 'circle' */
|
|
37
|
+
shape?: 'circle' | 'rounded';
|
|
38
|
+
className?: string;
|
|
39
|
+
dataTestId?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The one-line state under the title — a tone dot plus segments read as a
|
|
44
|
+
* sentence ("Aberto agora · Retirada no balcão").
|
|
45
|
+
*/
|
|
46
|
+
export interface AppHeaderStatusProps {
|
|
47
|
+
/** The dot's colour. Omit for no dot. */
|
|
48
|
+
tone?: ColorValue;
|
|
49
|
+
/** The segments, in reading order. Empty entries are dropped. */
|
|
50
|
+
items: ReactNode[];
|
|
51
|
+
/** What goes between segments. @default '·' */
|
|
52
|
+
separator?: ReactNode;
|
|
53
|
+
className?: string;
|
|
54
|
+
dataTestId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mark + title + status, optionally the control that discloses the details
|
|
59
|
+
* panel.
|
|
60
|
+
*
|
|
61
|
+
* There is deliberately no `href` here. A title that navigates is one line of
|
|
62
|
+
* the consumer's own router in the `leading` slot, whereas an `href` prop would
|
|
63
|
+
* have to guess whose link component it is being handed — `to` for one router,
|
|
64
|
+
* `href` for another — and would half-work for everybody. `onDisclose` is the
|
|
65
|
+
* only interaction the block owns: with it the block is a button and a chevron
|
|
66
|
+
* appears, without it the block is static text.
|
|
67
|
+
*/
|
|
68
|
+
export interface AppHeaderIdentityProps
|
|
69
|
+
extends Pick<AppHeaderBrandProps, 'logoUrl' | 'seedColor'> {
|
|
70
|
+
/** The name shown, and the one the mark derives its initials from. */
|
|
71
|
+
title: string;
|
|
72
|
+
/** A quieter line under the title — use `status` for the dotted state line. */
|
|
73
|
+
subtitle?: ReactNode;
|
|
74
|
+
/** The state line, usually an {@link AppHeaderStatusProps} element. */
|
|
75
|
+
status?: ReactNode;
|
|
76
|
+
/** Replaces the derived mark entirely (an icon, an `<img>`, nothing). */
|
|
77
|
+
mark?: ReactNode;
|
|
78
|
+
/** Opens the details panel. Renders the block as a button with a chevron. */
|
|
79
|
+
onDisclose?: () => void;
|
|
80
|
+
/** Whether the panel `onDisclose` opens is currently open (`aria-expanded`). */
|
|
81
|
+
disclosed?: boolean;
|
|
82
|
+
/** Accessible name of the disclosure. @default `Detalhes de {title}` */
|
|
83
|
+
discloseLabel?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The identity is still being resolved — hold a skeleton the size of the real
|
|
86
|
+
* block rather than a fallback name. An app that paints a placeholder title
|
|
87
|
+
* flashes the wrong brand for a frame on every load.
|
|
88
|
+
*/
|
|
89
|
+
loading?: boolean;
|
|
90
|
+
/** @default 'md' */
|
|
91
|
+
size?: SizeValue;
|
|
92
|
+
className?: string;
|
|
93
|
+
dataTestId?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The bar itself: a row of slots on a surface, with an optional second row that
|
|
98
|
+
* belongs to the same surface (search, tabs, chips).
|
|
99
|
+
*
|
|
100
|
+
* Every slot is a `ReactNode`, so the header knows nothing about sessions,
|
|
101
|
+
* routers, carts or stores — it lays out what it is handed.
|
|
102
|
+
*/
|
|
103
|
+
export interface AppHeaderProps {
|
|
104
|
+
/** Before the identity: a back arrow, a menu toggle, a logo. */
|
|
105
|
+
leading?: ReactNode;
|
|
106
|
+
/** The identity block. Usually an `AppHeaderIdentity`. */
|
|
107
|
+
children?: ReactNode;
|
|
108
|
+
/** Trailing controls: sign-in, cart, account avatar. */
|
|
109
|
+
actions?: ReactNode;
|
|
110
|
+
/**
|
|
111
|
+
* A small muted note pinned to the top-right corner above `actions` — a build
|
|
112
|
+
* tag, an environment name. Hidden from assistive tech is NOT the default: if
|
|
113
|
+
* it is worth showing it is worth reading.
|
|
114
|
+
*/
|
|
115
|
+
meta?: ReactNode;
|
|
116
|
+
/** A second row inside the same surface: a search field, tabs, filter chips. */
|
|
117
|
+
below?: ReactNode;
|
|
118
|
+
/** @default 'sticky' */
|
|
119
|
+
position?: AppHeaderPosition;
|
|
120
|
+
/**
|
|
121
|
+
* A CSS length the bar offsets itself by when `sticky`/`fixed` — for whatever
|
|
122
|
+
* already occupies the top of the viewport (an impersonation banner, an
|
|
123
|
+
* offline bar). Accepts a `var(--x, 0px)` so the offset can be published by
|
|
124
|
+
* the element itself. @default 0
|
|
125
|
+
*/
|
|
126
|
+
offsetTop?: number | string;
|
|
127
|
+
/**
|
|
128
|
+
* How wide the content may grow before it stops and centres. This is what
|
|
129
|
+
* makes one bar work on a phone and on a desktop: the surface still spans the
|
|
130
|
+
* viewport, the content does not. @default 1200
|
|
131
|
+
*/
|
|
132
|
+
maxWidth?: number | string;
|
|
133
|
+
/** Draw the bottom border. @default true */
|
|
134
|
+
divider?: boolean;
|
|
135
|
+
/** Raise a shadow once the page has scrolled under the bar. @default false */
|
|
136
|
+
elevateOnScroll?: boolean;
|
|
137
|
+
/** Tighter vertical rhythm, for a bar with no status line. @default false */
|
|
138
|
+
dense?: boolean;
|
|
139
|
+
/** Skip the flow spacer a `fixed` bar renders. @default false */
|
|
140
|
+
disableSpacer?: boolean;
|
|
141
|
+
className?: string;
|
|
142
|
+
dataTestId?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One label/value line in the details panel. */
|
|
146
|
+
export interface AppHeaderDetailRow {
|
|
147
|
+
/** Stable key; falls back to the label. */
|
|
148
|
+
id?: string;
|
|
149
|
+
label: string;
|
|
150
|
+
value: ReactNode;
|
|
151
|
+
/** Accent the value ("Aberto até 22h" in success green). */
|
|
152
|
+
tone?: ColorValue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The details panel's single full-width call to action. */
|
|
156
|
+
export interface AppHeaderDetailsAction {
|
|
157
|
+
label: string;
|
|
158
|
+
onClick: () => void;
|
|
159
|
+
/** @default 'primary' */
|
|
160
|
+
color?: ColorValue;
|
|
161
|
+
disabled?: boolean;
|
|
162
|
+
dataTestId?: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The panel the identity's disclosure opens.
|
|
167
|
+
*
|
|
168
|
+
* It is ONE component with two presentations because they are one thing to the
|
|
169
|
+
* user: a bottom sheet where the thumb is, a centred dialog where the pointer
|
|
170
|
+
* is. `presentation: 'auto'` picks by viewport so a caller never writes that
|
|
171
|
+
* branch — and never ships a phone-sized sheet stretched across a desktop.
|
|
172
|
+
*/
|
|
173
|
+
export interface AppHeaderDetailsProps {
|
|
174
|
+
open: boolean;
|
|
175
|
+
onClose: () => void;
|
|
176
|
+
title: string;
|
|
177
|
+
subtitle?: ReactNode;
|
|
178
|
+
/** The label/value lines. Rendered above `children`. */
|
|
179
|
+
rows?: AppHeaderDetailRow[];
|
|
180
|
+
/** Anything the rows cannot express — a map, a note, a list. */
|
|
181
|
+
children?: ReactNode;
|
|
182
|
+
/** The single full-width action at the foot of the panel. */
|
|
183
|
+
action?: AppHeaderDetailsAction;
|
|
184
|
+
/** @default 'auto' */
|
|
185
|
+
presentation?: AppHeaderDetailsPresentation;
|
|
186
|
+
/** Below this breakpoint `auto` chooses the sheet. @default 'sm' */
|
|
187
|
+
breakpoint?: Breakpoint;
|
|
188
|
+
className?: string;
|
|
189
|
+
dataTestId?: string;
|
|
190
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { AppHeader, default } from './AppHeader';
|
|
2
|
+
export { AppHeaderBrand } from './AppHeader.brand';
|
|
3
|
+
export { AppHeaderDetails } from './AppHeader.details';
|
|
4
|
+
export { AppHeaderIdentity, AppHeaderStatus } from './AppHeader.identity';
|
|
5
|
+
export { brandGradient, highlightOf, initialsOf } from './AppHeader.colors';
|
|
6
|
+
export type {
|
|
7
|
+
AppHeaderBrandProps,
|
|
8
|
+
AppHeaderDetailRow,
|
|
9
|
+
AppHeaderDetailsAction,
|
|
10
|
+
AppHeaderDetailsPresentation,
|
|
11
|
+
AppHeaderDetailsProps,
|
|
12
|
+
AppHeaderIdentityProps,
|
|
13
|
+
AppHeaderPosition,
|
|
14
|
+
AppHeaderProps,
|
|
15
|
+
AppHeaderStatusProps,
|
|
16
|
+
} from './AppHeader.types';
|