@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
package/components-catalog.md
CHANGED
|
@@ -569,6 +569,14 @@ Components for structuring and organizing page layouts.
|
|
|
569
569
|
|
|
570
570
|
Components for site and app navigation.
|
|
571
571
|
|
|
572
|
+
### AppHeader
|
|
573
|
+
**Purpose**: The application bar — brand identity, state line, actions, and the details panel behind them
|
|
574
|
+
**Use Cases**: Storefront header, back-office top bar, platform console header; any app bar that must serve a phone and a desktop from one component
|
|
575
|
+
**Features**: Slot-based (`leading` / identity / `actions` / `meta` / `below`), context-agnostic (no router, session or data), brand mark with a gradient derived from ONE seed colour, dotted state line, disclosure panel that is a bottom sheet on phones and a centred dialog on large screens, sticky/fixed positioning with a self-measured spacer
|
|
576
|
+
**Types**: `AppHeaderProps`, `AppHeaderIdentityProps`, `AppHeaderBrandProps`, `AppHeaderStatusProps`, `AppHeaderDetailsProps`, `AppHeaderDetailRow`
|
|
577
|
+
**Location**: `src/components/navigation/AppHeader`
|
|
578
|
+
**Docs**: `src/components/navigation/AppHeader/AppHeader.md`
|
|
579
|
+
|
|
572
580
|
### Breadcrumbs
|
|
573
581
|
**Purpose**: Display navigation path
|
|
574
582
|
**Use Cases**: Hierarchical navigation, page location indicator
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"./layout/Skeleton": "./src/components/layout/Skeleton/index.ts",
|
|
121
121
|
"./layout/Spacer": "./src/components/layout/Spacer/index.ts",
|
|
122
122
|
"./layout/TableFilter": "./src/components/layout/TableFilter/index.ts",
|
|
123
|
+
"./navigation/AppHeader": "./src/components/navigation/AppHeader/index.ts",
|
|
123
124
|
"./navigation/Breadcrumbs": "./src/components/navigation/Breadcrumbs/index.ts",
|
|
124
125
|
"./navigation/CommandPalette": "./src/components/navigation/CommandPalette/index.ts",
|
|
125
126
|
"./navigation/ContextMenu": "./src/components/navigation/ContextMenu/index.ts",
|
|
@@ -93,7 +93,7 @@ export const Chart: React.FC<ChartProps> = (props) => {
|
|
|
93
93
|
<ChartContainer
|
|
94
94
|
responsive={props.responsive ?? true}
|
|
95
95
|
width={props.width ?? '100%'}
|
|
96
|
-
height={sizeStyles.height}
|
|
96
|
+
height={props.fillHeight === true ? '100%' : sizeStyles.height}
|
|
97
97
|
data-testid={`${dataTestId}-container`}
|
|
98
98
|
>
|
|
99
99
|
{renderChartByType(props, theme)}
|
|
@@ -83,6 +83,16 @@ export interface ChartProps {
|
|
|
83
83
|
maxCategoryTicks?: number;
|
|
84
84
|
/** Category tick labels longer than this are truncated with an ellipsis. Default 12. */
|
|
85
85
|
tickLabelMaxChars?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Take the height of the box this chart is placed in, rather than the size
|
|
88
|
+
* preset's fixed one.
|
|
89
|
+
*
|
|
90
|
+
* For a host that has already sized the chart's container — a dashboard cell
|
|
91
|
+
* with a chosen height, say. The preset is a sensible default precisely
|
|
92
|
+
* because most callers have NOT sized anything, so this is opt-in: without
|
|
93
|
+
* it a chart in a box with no definite height would collapse to nothing.
|
|
94
|
+
*/
|
|
95
|
+
fillHeight?: boolean;
|
|
86
96
|
/** Bar corner radius in px (top corners only; stacked segments stay square). Default 3. */
|
|
87
97
|
barRadius?: number;
|
|
88
98
|
/**
|
|
@@ -15,7 +15,16 @@ export const ChartTooltip = Tooltip;
|
|
|
15
15
|
export const ChartLegend = Legend;
|
|
16
16
|
|
|
17
17
|
export interface ChartContainerProps {
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A number of pixels, or a PERCENTAGE of the box this container is given.
|
|
20
|
+
*
|
|
21
|
+
* The difference is not cosmetic: Recharts sizes the chart from the number
|
|
22
|
+
* when it is one and only MEASURES its own box when it is a percentage
|
|
23
|
+
* (`calculateChartDimensions`). So a container stretched by its parent's
|
|
24
|
+
* layout goes on drawing a 300px chart inside a 600px box — visibly, as
|
|
25
|
+
* blank space under the plot — until the height is handed over as a percent.
|
|
26
|
+
*/
|
|
27
|
+
height: number | `${number}%`;
|
|
19
28
|
width?: number | string;
|
|
20
29
|
responsive?: boolean;
|
|
21
30
|
children: React.ReactElement;
|
|
@@ -12,6 +12,8 @@ export interface SpecChartProps {
|
|
|
12
12
|
spec: ChartSpec;
|
|
13
13
|
data: ChartDataPoint[];
|
|
14
14
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
15
|
+
/** Take the height of the box this chart is placed in — see `ChartProps`. */
|
|
16
|
+
fillHeight?: boolean;
|
|
15
17
|
loading?: boolean;
|
|
16
18
|
className?: string;
|
|
17
19
|
onClick?: (point: ChartDataPoint) => void;
|
|
@@ -27,6 +29,7 @@ export const SpecChart: React.FC<SpecChartProps> = ({
|
|
|
27
29
|
spec,
|
|
28
30
|
data,
|
|
29
31
|
size,
|
|
32
|
+
fillHeight,
|
|
30
33
|
loading,
|
|
31
34
|
className,
|
|
32
35
|
onClick,
|
|
@@ -45,6 +48,7 @@ export const SpecChart: React.FC<SpecChartProps> = ({
|
|
|
45
48
|
<Chart
|
|
46
49
|
{...chartProps}
|
|
47
50
|
size={size}
|
|
51
|
+
fillHeight={fillHeight}
|
|
48
52
|
loading={loading}
|
|
49
53
|
className={className}
|
|
50
54
|
onClick={onClick}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
|
|
4
4
|
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
|
|
5
|
+
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
6
|
+
import type { Theme } from "@mui/material/styles";
|
|
5
7
|
|
|
6
8
|
import type { SortFieldDefinition } from "../../layout/ContentToolbar";
|
|
7
9
|
import { Box } from "../../../mui/Box";
|
|
@@ -286,9 +288,44 @@ const LAYOUT_TILE_LABELS: Record<DataViewsLayout, string> = {
|
|
|
286
288
|
* `data-views-glyphs.tsx` for why. A layout this table cannot render is simply
|
|
287
289
|
* not offered, and the panel says why rather than leaving a gap.
|
|
288
290
|
*/
|
|
291
|
+
/**
|
|
292
|
+
* The three density tiles — or, on a phone, a line saying why there are none:
|
|
293
|
+
* the card grid is `1fr` there, so all three would draw one card per row. Says
|
|
294
|
+
* so rather than vanishing, for the same reason as the board note below it.
|
|
295
|
+
*/
|
|
296
|
+
function DensityTiles({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element {
|
|
297
|
+
const { layout, density, setDensity } = useDataViewsLayout();
|
|
298
|
+
// Matches the card grid's own `xs: "1fr"` track, so the control and the thing
|
|
299
|
+
// it controls agree on where density stops meaning anything.
|
|
300
|
+
const isOneColumn = useMediaQuery((t: Theme) => t.breakpoints.down("sm"));
|
|
301
|
+
if (layout === "cards" && isOneColumn) {
|
|
302
|
+
return (
|
|
303
|
+
<Text variant="caption" as="p">
|
|
304
|
+
<Box component="span" sx={{ px: 0.5, pt: 0.5, display: "block", color: "text.disabled" }}>
|
|
305
|
+
Um card por linha nesta largura — a densidade volta em telas maiores.
|
|
306
|
+
</Box>
|
|
307
|
+
</Text>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.75, mt: 0.5 }}>
|
|
312
|
+
{densityTiles(layout).map((tile) => (
|
|
313
|
+
<Tile
|
|
314
|
+
key={tile.value}
|
|
315
|
+
active={density === tile.value}
|
|
316
|
+
label={tile.label}
|
|
317
|
+
onClick={() => setDensity(tile.value)}
|
|
318
|
+
testId={`${testIdPrefix}-density-${tile.value}`}
|
|
319
|
+
>
|
|
320
|
+
{densityGlyph(layout, tile, density === tile.value)}
|
|
321
|
+
</Tile>
|
|
322
|
+
))}
|
|
323
|
+
</Box>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
289
327
|
export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element {
|
|
290
|
-
const { layout, setLayout, canUseCards, canUseList, canUseBoard
|
|
291
|
-
useDataViewsLayout();
|
|
328
|
+
const { layout, setLayout, canUseCards, canUseList, canUseBoard } = useDataViewsLayout();
|
|
292
329
|
const available: DataViewsLayout[] = [
|
|
293
330
|
"table",
|
|
294
331
|
...(canUseList ? (["list"] as const) : []),
|
|
@@ -336,19 +373,7 @@ export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JS
|
|
|
336
373
|
{DENSITY_HEADINGS[layout]}
|
|
337
374
|
</Box>
|
|
338
375
|
</Text>
|
|
339
|
-
<
|
|
340
|
-
{densityTiles(layout).map((tile) => (
|
|
341
|
-
<Tile
|
|
342
|
-
key={tile.value}
|
|
343
|
-
active={density === tile.value}
|
|
344
|
-
label={tile.label}
|
|
345
|
-
onClick={() => setDensity(tile.value)}
|
|
346
|
-
testId={`${testIdPrefix}-density-${tile.value}`}
|
|
347
|
-
>
|
|
348
|
-
{densityGlyph(layout, tile, density === tile.value)}
|
|
349
|
-
</Tile>
|
|
350
|
-
))}
|
|
351
|
-
</Box>
|
|
376
|
+
<DensityTiles testIdPrefix={testIdPrefix} />
|
|
352
377
|
{!canUseBoard && (
|
|
353
378
|
<Text variant="caption" as="p">
|
|
354
379
|
<Box component="span" sx={{ px: 0.5, pt: 1.25, display: "block", color: "text.disabled" }}>
|
|
@@ -6,14 +6,14 @@ import { DataGrid, type GridColumn, type GridSort } from "../DataGrid";
|
|
|
6
6
|
import { Box } from "../../../mui/Box";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
cardTargetWidthFor,
|
|
10
10
|
cardScaleForZoom,
|
|
11
11
|
DENSITY_BOARD_SCALE,
|
|
12
|
-
DENSITY_CARD_COLUMNS,
|
|
13
12
|
DENSITY_ROW_PADDING,
|
|
14
13
|
useDataViewsLayout,
|
|
15
14
|
type DataViewsDensity,
|
|
16
15
|
} from "./data-views-layout-context";
|
|
16
|
+
import { cardGridTracks } from "./data-views-grid-helpers";
|
|
17
17
|
import { DataViewsBoard, type BoardConfig } from "./DataViewsBoard";
|
|
18
18
|
import { SelectAllStrip } from "./data-views-select-all-strip";
|
|
19
19
|
import { ListCardGroup, type ListGroupConfig } from "./list-card-rails";
|
|
@@ -90,9 +90,8 @@ interface CardBodyProps<T extends Record<string, unknown>> {
|
|
|
90
90
|
getRowId: (row: T) => string | number;
|
|
91
91
|
selectedIds: Set<string | number>;
|
|
92
92
|
onToggleId: (id: string | number) => void;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
maxColumns: number;
|
|
93
|
+
/** The width each card asks for; `auto-fill` turns it into a column count. */
|
|
94
|
+
targetCardWidth: number;
|
|
96
95
|
/** Content scale (padding + type) handed to each card, from the zoom slider. */
|
|
97
96
|
cardScale: number;
|
|
98
97
|
dataTestId?: string;
|
|
@@ -112,8 +111,7 @@ function CardBody<T extends Record<string, unknown>>({
|
|
|
112
111
|
getRowId,
|
|
113
112
|
selectedIds,
|
|
114
113
|
onToggleId,
|
|
115
|
-
|
|
116
|
-
maxColumns,
|
|
114
|
+
targetCardWidth,
|
|
117
115
|
cardScale,
|
|
118
116
|
dataTestId,
|
|
119
117
|
emptyState,
|
|
@@ -129,12 +127,7 @@ function CardBody<T extends Record<string, unknown>>({
|
|
|
129
127
|
// Fixed inter-card gap — deliberately NOT scaled by the zoom slider, so
|
|
130
128
|
// only the cards grow while the space between them stays constant.
|
|
131
129
|
gap: 1.5,
|
|
132
|
-
|
|
133
|
-
// ALLOWED, so "Poucos" reads as bigger cards rather than more of them.
|
|
134
|
-
gridTemplateColumns: {
|
|
135
|
-
xs: "1fr",
|
|
136
|
-
sm: `repeat(auto-fill, minmax(max(${minCardWidth}px, calc((100% - ${(maxColumns - 1) * 12}px) / ${maxColumns})), 1fr))`,
|
|
137
|
-
},
|
|
130
|
+
...cardGridTracks(targetCardWidth),
|
|
138
131
|
}}
|
|
139
132
|
data-testid={dataTestId ? `${dataTestId}-cards` : "data-views-cards"}
|
|
140
133
|
>
|
|
@@ -353,8 +346,7 @@ function headerlessBody<T extends Record<string, unknown>>(
|
|
|
353
346
|
getRowId={getRowId}
|
|
354
347
|
selectedIds={c.selectedIds}
|
|
355
348
|
onToggleId={c.toggleId}
|
|
356
|
-
|
|
357
|
-
maxColumns={DENSITY_CARD_COLUMNS[density]}
|
|
349
|
+
targetCardWidth={cardTargetWidthFor(zoom, density)}
|
|
358
350
|
cardScale={cardScaleForZoom(zoom)}
|
|
359
351
|
dataTestId={dataTestId}
|
|
360
352
|
emptyState={emptyState}
|
|
@@ -246,3 +246,24 @@ export function togglePillValues(current: string[] | undefined, value: string, c
|
|
|
246
246
|
const list = current ?? [];
|
|
247
247
|
return checked ? [...list, value] : list.filter((v) => v !== value);
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The card grid's `grid-template-columns`, as an sx fragment.
|
|
252
|
+
*
|
|
253
|
+
* One rule at every width: each card asks for `targetWidth` and `auto-fill`
|
|
254
|
+
* decides how many fit. Density moves the target, so it changes the count at
|
|
255
|
+
* every size — which the previous column-count cap could not do, because its
|
|
256
|
+
* track floor bottomed out at a constant shared by all three densities.
|
|
257
|
+
*
|
|
258
|
+
* Below `sm` this is a single column, and no target changes that. That is why
|
|
259
|
+
* the density control hides itself there rather than offering three settings
|
|
260
|
+
* that all draw the same thing.
|
|
261
|
+
*/
|
|
262
|
+
export function cardGridTracks(targetWidth: number) {
|
|
263
|
+
return {
|
|
264
|
+
gridTemplateColumns: {
|
|
265
|
+
xs: "1fr",
|
|
266
|
+
sm: `repeat(auto-fill, minmax(${targetWidth}px, 1fr))`,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -81,13 +81,32 @@ export const DENSITY_ROW_PADDING: Record<DataViewsDensity, number> = {
|
|
|
81
81
|
comfortable: 1.5,
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The card WIDTH each density aims for, as a multiple of the base card width.
|
|
86
|
+
*
|
|
87
|
+
* Density used to cap the column COUNT, with a track floor of
|
|
88
|
+
* `max(baseWidth, 100% / count)`. That base is a constant, so wherever it won
|
|
89
|
+
* the arithmetic every density produced an identical track — "Muitos" and
|
|
90
|
+
* "Medio" both drew 3 cards of 279px at md — and where the percentage won
|
|
91
|
+
* instead, on a wide screen, the count was pinned and each card grew to a share
|
|
92
|
+
* of the viewport: ~430px of card holding one chip and two lines of text.
|
|
93
|
+
*
|
|
94
|
+
* One width per density fixes both, because the number `auto-fill` divides by
|
|
95
|
+
* now differs per density at every size. The spread is chosen so the three land
|
|
96
|
+
* on DIFFERENT column counts across the range rather than by eye — 4/3/2 at md,
|
|
97
|
+
* 6/4/3 at lg, 7/5/4 at xl, 13/9/8 at 2560.
|
|
98
|
+
*/
|
|
99
|
+
export const DENSITY_CARD_SCALE: Record<DataViewsDensity, number> = {
|
|
100
|
+
compact: 0.84,
|
|
101
|
+
cozy: 1.15,
|
|
102
|
+
comfortable: 1.4,
|
|
89
103
|
};
|
|
90
104
|
|
|
105
|
+
/** The width a card aims for: the base width, scaled by zoom, then by density. */
|
|
106
|
+
export function cardTargetWidthFor(zoom: number, density: DataViewsDensity): number {
|
|
107
|
+
return Math.round(cardMinWidthForZoom(zoom) * DENSITY_CARD_SCALE[density]);
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
/**
|
|
92
111
|
* The BOARD's column-width multiplier per density.
|
|
93
112
|
*
|
|
@@ -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
|
+
}
|