@12-apps/ui 3.2.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/data-display/Chart/Chart.tsx +1 -1
- package/src/components/data-display/Chart/Chart.types.ts +10 -0
- package/src/components/data-display/Chart/ChartComposables.tsx +10 -1
- package/src/components/data-display/Chart/SpecChart.tsx +4 -0
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +40 -15
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +7 -15
- package/src/components/data-display/DataViews/data-views-grid-helpers.tsx +21 -0
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +24 -5
- 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
|
@@ -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.
|