@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.
@@ -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.0",
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';
@@ -1,3 +1,4 @@
1
+ export * from './AppHeader';
1
2
  export * from './Breadcrumbs/Breadcrumbs';
2
3
  export * from './CommandPalette';
3
4
  export * from './ContextMenu';