@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,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';