@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.
@@ -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.