@spatika/react 1.1.0 → 1.2.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/AGENTS.md +71 -0
- package/README.md +4 -3
- package/dist/composites/Card.d.ts +7 -1
- package/dist/composites/Card.d.ts.map +1 -1
- package/dist/composites/Card.js +10 -0
- package/dist/composites/ChartDataGrid.d.ts +33 -0
- package/dist/composites/ChartDataGrid.d.ts.map +1 -0
- package/dist/composites/ChartDataGrid.js +77 -0
- package/dist/composites/CoverHero.d.ts +1 -1
- package/dist/composites/CoverHero.d.ts.map +1 -1
- package/dist/composites/CoverHero.js +2 -2
- package/dist/composites/EntityMediaCard.d.ts +1 -1
- package/dist/composites/EntityMediaCard.d.ts.map +1 -1
- package/dist/composites/EntityMediaCard.js +12 -4
- package/dist/composites/EventCalendar.d.ts +35 -0
- package/dist/composites/EventCalendar.d.ts.map +1 -0
- package/dist/composites/EventCalendar.js +339 -0
- package/dist/composites/EventEditor.d.ts +22 -0
- package/dist/composites/EventEditor.d.ts.map +1 -0
- package/dist/composites/EventEditor.js +86 -0
- package/dist/composites/EventTimeline.d.ts +24 -0
- package/dist/composites/EventTimeline.d.ts.map +1 -0
- package/dist/composites/EventTimeline.js +184 -0
- package/dist/composites/MapChart.d.ts +24 -0
- package/dist/composites/MapChart.d.ts.map +1 -0
- package/dist/composites/MapChart.js +58 -0
- package/dist/composites/ProfileHero.d.ts +6 -4
- package/dist/composites/ProfileHero.d.ts.map +1 -1
- package/dist/composites/ProfileHero.js +8 -8
- package/dist/composites/ScatterWebGL.d.ts +22 -0
- package/dist/composites/ScatterWebGL.d.ts.map +1 -0
- package/dist/composites/ScatterWebGL.js +45 -0
- package/dist/composites/SiteFooter.d.ts +1 -1
- package/dist/composites/SiteFooter.js +1 -1
- package/dist/composites/SiteNav.d.ts +7 -2
- package/dist/composites/SiteNav.d.ts.map +1 -1
- package/dist/composites/SiteNav.js +9 -5
- package/dist/composites/StatCard.d.ts +2 -1
- package/dist/composites/StatCard.d.ts.map +1 -1
- package/dist/composites/StatCard.js +3 -3
- package/dist/composites/StatusDot.d.ts +1 -1
- package/dist/composites/Wordmark.d.ts +2 -2
- package/dist/composites/Wordmark.d.ts.map +1 -1
- package/dist/composites/cartesian-charts.d.ts +138 -0
- package/dist/composites/cartesian-charts.d.ts.map +1 -0
- package/dist/composites/cartesian-charts.js +401 -0
- package/dist/composites/chart-composition.d.ts +139 -0
- package/dist/composites/chart-composition.d.ts.map +1 -0
- package/dist/composites/chart-composition.js +486 -0
- package/dist/composites/chart-ui.d.ts +83 -0
- package/dist/composites/chart-ui.d.ts.map +1 -0
- package/dist/composites/chart-ui.js +108 -0
- package/dist/composites/charts-3d.d.ts +37 -0
- package/dist/composites/charts-3d.d.ts.map +1 -0
- package/dist/composites/charts-3d.js +121 -0
- package/dist/composites/flow-charts.d.ts +89 -0
- package/dist/composites/flow-charts.d.ts.map +1 -0
- package/dist/composites/flow-charts.js +186 -0
- package/dist/composites/radial-charts.d.ts +109 -0
- package/dist/composites/radial-charts.d.ts.map +1 -0
- package/dist/composites/radial-charts.js +176 -0
- package/dist/composites/scheduler-ui.d.ts +43 -0
- package/dist/composites/scheduler-ui.d.ts.map +1 -0
- package/dist/composites/scheduler-ui.js +71 -0
- package/dist/index.d.ts +67 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -7
- package/dist/lib/chart-export.d.ts +10 -0
- package/dist/lib/chart-export.d.ts.map +1 -0
- package/dist/lib/chart-export.js +102 -0
- package/dist/lib/charts.d.ts +235 -0
- package/dist/lib/charts.d.ts.map +1 -0
- package/dist/lib/charts.js +744 -0
- package/dist/lib/geo.d.ts +39 -0
- package/dist/lib/geo.d.ts.map +1 -0
- package/dist/lib/geo.js +101 -0
- package/dist/lib/iso.d.ts +21 -0
- package/dist/lib/iso.d.ts.map +1 -0
- package/dist/lib/iso.js +56 -0
- package/dist/lib/rrule.d.ts +20 -0
- package/dist/lib/rrule.d.ts.map +1 -0
- package/dist/lib/rrule.js +236 -0
- package/dist/lib/scheduler.d.ts +152 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/lib/scheduler.js +501 -0
- package/dist/lib/webgl-scatter.d.ts +44 -0
- package/dist/lib/webgl-scatter.d.ts.map +1 -0
- package/dist/lib/webgl-scatter.js +185 -0
- package/dist/primitives/Accordion.d.ts +44 -20
- package/dist/primitives/Accordion.d.ts.map +1 -1
- package/dist/primitives/Accordion.js +82 -22
- package/dist/primitives/AppBar.d.ts +13 -0
- package/dist/primitives/AppBar.d.ts.map +1 -0
- package/dist/primitives/AppBar.js +27 -0
- package/dist/primitives/Autocomplete.d.ts +28 -0
- package/dist/primitives/Autocomplete.d.ts.map +1 -0
- package/dist/primitives/Autocomplete.js +118 -0
- package/dist/primitives/Avatar.d.ts +13 -1
- package/dist/primitives/Avatar.d.ts.map +1 -1
- package/dist/primitives/Avatar.js +17 -2
- package/dist/primitives/Backdrop.d.ts +17 -0
- package/dist/primitives/Backdrop.d.ts.map +1 -0
- package/dist/primitives/Backdrop.js +17 -0
- package/dist/primitives/BottomNavigation.d.ts +23 -0
- package/dist/primitives/BottomNavigation.d.ts.map +1 -0
- package/dist/primitives/BottomNavigation.js +30 -0
- package/dist/primitives/ButtonBase.d.ts +14 -0
- package/dist/primitives/ButtonBase.d.ts.map +1 -0
- package/dist/primitives/ButtonBase.js +13 -0
- package/dist/primitives/ButtonGroup.d.ts +25 -0
- package/dist/primitives/ButtonGroup.d.ts.map +1 -0
- package/dist/primitives/ButtonGroup.js +44 -0
- package/dist/primitives/CircularProgress.d.ts +16 -0
- package/dist/primitives/CircularProgress.d.ts.map +1 -0
- package/dist/primitives/CircularProgress.js +17 -0
- package/dist/primitives/ClickAwayListener.d.ts +16 -0
- package/dist/primitives/ClickAwayListener.d.ts.map +1 -0
- package/dist/primitives/ClickAwayListener.js +33 -0
- package/dist/primitives/Collapse.d.ts +19 -0
- package/dist/primitives/Collapse.d.ts.map +1 -0
- package/dist/primitives/Collapse.js +14 -0
- package/dist/primitives/Container.d.ts +13 -0
- package/dist/primitives/Container.d.ts.map +1 -0
- package/dist/primitives/Container.js +26 -0
- package/dist/primitives/Fab.d.ts +22 -0
- package/dist/primitives/Fab.d.ts.map +1 -0
- package/dist/primitives/Fab.js +42 -0
- package/dist/primitives/FormControl.d.ts +27 -0
- package/dist/primitives/FormControl.d.ts.map +1 -0
- package/dist/primitives/FormControl.js +24 -0
- package/dist/primitives/FormControlLabel.d.ts +18 -0
- package/dist/primitives/FormControlLabel.d.ts.map +1 -0
- package/dist/primitives/FormControlLabel.js +19 -0
- package/dist/primitives/Grid.d.ts +32 -0
- package/dist/primitives/Grid.d.ts.map +1 -0
- package/dist/primitives/Grid.js +50 -0
- package/dist/primitives/IconButton.d.ts +22 -0
- package/dist/primitives/IconButton.d.ts.map +1 -0
- package/dist/primitives/IconButton.js +39 -0
- package/dist/primitives/ImageList.d.ts +31 -0
- package/dist/primitives/ImageList.d.ts.map +1 -0
- package/dist/primitives/ImageList.js +33 -0
- package/dist/primitives/InputAdornment.d.ts +28 -0
- package/dist/primitives/InputAdornment.d.ts.map +1 -0
- package/dist/primitives/InputAdornment.js +13 -0
- package/dist/primitives/Link.d.ts +17 -0
- package/dist/primitives/Link.d.ts.map +1 -0
- package/dist/primitives/Link.js +29 -0
- package/dist/primitives/List.d.ts +41 -0
- package/dist/primitives/List.d.ts.map +1 -0
- package/dist/primitives/List.js +22 -0
- package/dist/primitives/Masonry.d.ts +14 -0
- package/dist/primitives/Masonry.d.ts.map +1 -0
- package/dist/primitives/Masonry.js +15 -0
- package/dist/primitives/Menu.d.ts +28 -0
- package/dist/primitives/Menu.d.ts.map +1 -0
- package/dist/primitives/Menu.js +54 -0
- package/dist/primitives/MobileStepper.d.ts +15 -0
- package/dist/primitives/MobileStepper.d.ts.map +1 -0
- package/dist/primitives/MobileStepper.js +8 -0
- package/dist/primitives/Modal.d.ts +18 -0
- package/dist/primitives/Modal.d.ts.map +1 -0
- package/dist/primitives/Modal.js +29 -0
- package/dist/primitives/NoSsr.d.ts +15 -0
- package/dist/primitives/NoSsr.d.ts.map +1 -0
- package/dist/primitives/NoSsr.js +19 -0
- package/dist/primitives/Paper.d.ts +22 -0
- package/dist/primitives/Paper.d.ts.map +1 -0
- package/dist/primitives/Paper.js +33 -0
- package/dist/primitives/Progress.d.ts +6 -2
- package/dist/primitives/Progress.d.ts.map +1 -1
- package/dist/primitives/Progress.js +8 -3
- package/dist/primitives/Snackbar.d.ts +23 -0
- package/dist/primitives/Snackbar.d.ts.map +1 -0
- package/dist/primitives/Snackbar.js +33 -0
- package/dist/primitives/SpeedDial.d.ts +26 -0
- package/dist/primitives/SpeedDial.d.ts.map +1 -0
- package/dist/primitives/SpeedDial.js +26 -0
- package/dist/primitives/Stack.d.ts +15 -0
- package/dist/primitives/Stack.d.ts.map +1 -0
- package/dist/primitives/Stack.js +45 -0
- package/dist/primitives/StarRating.d.ts +19 -1
- package/dist/primitives/StarRating.d.ts.map +1 -1
- package/dist/primitives/StarRating.js +36 -6
- package/dist/primitives/Stepper.d.ts +36 -0
- package/dist/primitives/Stepper.d.ts.map +1 -0
- package/dist/primitives/Stepper.js +53 -0
- package/dist/primitives/TablePagination.d.ts +16 -0
- package/dist/primitives/TablePagination.d.ts.map +1 -0
- package/dist/primitives/TablePagination.js +12 -0
- package/dist/primitives/TextField.d.ts +24 -0
- package/dist/primitives/TextField.d.ts.map +1 -0
- package/dist/primitives/TextField.js +19 -0
- package/dist/primitives/TextareaAutosize.d.ts +11 -0
- package/dist/primitives/TextareaAutosize.d.ts.map +1 -0
- package/dist/primitives/TextareaAutosize.js +26 -0
- package/dist/primitives/ToggleButton.d.ts +28 -0
- package/dist/primitives/ToggleButton.d.ts.map +1 -0
- package/dist/primitives/ToggleButton.js +56 -0
- package/dist/primitives/TransferList.d.ts +21 -0
- package/dist/primitives/TransferList.d.ts.map +1 -0
- package/dist/primitives/TransferList.js +40 -0
- package/dist/primitives/Typography.d.ts +23 -0
- package/dist/primitives/Typography.d.ts.map +1 -0
- package/dist/primitives/Typography.js +69 -0
- package/dist/primitives/transitions.d.ts +29 -0
- package/dist/primitives/transitions.d.ts.map +1 -0
- package/dist/primitives/transitions.js +52 -0
- package/package.json +28 -4
- package/skills/spatika-ui/SKILL.md +96 -0
- package/src/composites/Card.tsx +51 -1
- package/src/composites/ChartDataGrid.test.tsx +57 -0
- package/src/composites/ChartDataGrid.tsx +196 -0
- package/src/composites/CoverHero.test.tsx +38 -0
- package/src/composites/CoverHero.tsx +29 -22
- package/src/composites/EntityMediaCard.test.tsx +58 -0
- package/src/composites/EntityMediaCard.tsx +31 -13
- package/src/composites/EventCalendar.tsx +928 -0
- package/src/composites/EventEditor.tsx +268 -0
- package/src/composites/EventTimeline.tsx +357 -0
- package/src/composites/MapChart.test.tsx +39 -0
- package/src/composites/MapChart.tsx +130 -0
- package/src/composites/ProfileHero.test.tsx +33 -0
- package/src/composites/ProfileHero.tsx +96 -79
- package/src/composites/ScatterWebGL.test.tsx +25 -0
- package/src/composites/ScatterWebGL.tsx +79 -0
- package/src/composites/SiteFooter.test.tsx +37 -0
- package/src/composites/SiteFooter.tsx +1 -1
- package/src/composites/SiteNav.test.tsx +46 -0
- package/src/composites/SiteNav.tsx +14 -4
- package/src/composites/StatCard.test.tsx +30 -0
- package/src/composites/StatCard.tsx +4 -0
- package/src/composites/Wordmark.test.tsx +23 -0
- package/src/composites/Wordmark.tsx +2 -2
- package/src/composites/cartesian-charts.test.tsx +52 -0
- package/src/composites/cartesian-charts.tsx +1287 -0
- package/src/composites/chart-composition.test.tsx +53 -0
- package/src/composites/chart-composition.tsx +898 -0
- package/src/composites/chart-ui.tsx +328 -0
- package/src/composites/charts-3d.test.tsx +25 -0
- package/src/composites/charts-3d.tsx +318 -0
- package/src/composites/charts.test.tsx +254 -0
- package/src/composites/chrome.test.tsx +29 -0
- package/src/composites/flow-charts.test.tsx +79 -0
- package/src/composites/flow-charts.tsx +607 -0
- package/src/composites/marketing.test.tsx +38 -34
- package/src/composites/radial-charts.test.tsx +74 -0
- package/src/composites/radial-charts.tsx +617 -0
- package/src/composites/scheduler-ui.tsx +304 -0
- package/src/composites/scheduler.test.tsx +194 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +277 -5
- package/src/lib/chart-export.test.ts +20 -0
- package/src/lib/chart-export.ts +99 -0
- package/src/lib/charts-platform.test.ts +93 -0
- package/src/lib/charts.test.ts +132 -0
- package/src/lib/charts.ts +1021 -0
- package/src/lib/geo.test.ts +42 -0
- package/src/lib/geo.ts +138 -0
- package/src/lib/iso.test.ts +22 -0
- package/src/lib/iso.ts +102 -0
- package/src/lib/rrule.test.ts +53 -0
- package/src/lib/rrule.ts +281 -0
- package/src/lib/scheduler.test.ts +186 -0
- package/src/lib/scheduler.ts +688 -0
- package/src/lib/tokens.test.ts +2 -2
- package/src/lib/webgl-scatter.test.ts +36 -0
- package/src/lib/webgl-scatter.ts +230 -0
- package/src/primitives/Accordion.tsx +218 -63
- package/src/primitives/AppBar.tsx +39 -0
- package/src/primitives/Autocomplete.tsx +236 -0
- package/src/primitives/Avatar.tsx +52 -1
- package/src/primitives/Backdrop.tsx +48 -0
- package/src/primitives/BottomNavigation.tsx +102 -0
- package/src/primitives/ButtonBase.tsx +34 -0
- package/src/primitives/ButtonGroup.tsx +90 -0
- package/src/primitives/CircularProgress.tsx +104 -0
- package/src/primitives/ClickAwayListener.tsx +46 -0
- package/src/primitives/Collapse.tsx +39 -0
- package/src/primitives/Container.tsx +40 -0
- package/src/primitives/Fab.tsx +62 -0
- package/src/primitives/FormControl.tsx +90 -0
- package/src/primitives/FormControlLabel.tsx +51 -0
- package/src/primitives/Grid.tsx +101 -0
- package/src/primitives/IconButton.tsx +59 -0
- package/src/primitives/ImageList.test.tsx +48 -0
- package/src/primitives/ImageList.tsx +112 -0
- package/src/primitives/InputAdornment.tsx +86 -0
- package/src/primitives/Link.tsx +48 -0
- package/src/primitives/List.tsx +121 -0
- package/src/primitives/Masonry.tsx +32 -0
- package/src/primitives/Menu.tsx +112 -0
- package/src/primitives/MobileStepper.tsx +64 -0
- package/src/primitives/Modal.tsx +70 -0
- package/src/primitives/NoSsr.tsx +28 -0
- package/src/primitives/Paper.tsx +55 -0
- package/src/primitives/Progress.tsx +25 -7
- package/src/primitives/Snackbar.tsx +83 -0
- package/src/primitives/SpeedDial.tsx +110 -0
- package/src/primitives/Stack.tsx +88 -0
- package/src/primitives/StarRating.tsx +87 -28
- package/src/primitives/Stepper.tsx +184 -0
- package/src/primitives/TablePagination.tsx +58 -0
- package/src/primitives/TextField.tsx +105 -0
- package/src/primitives/TextareaAutosize.tsx +49 -0
- package/src/primitives/ToggleButton.tsx +148 -0
- package/src/primitives/TransferList.tsx +110 -0
- package/src/primitives/Typography.tsx +90 -0
- package/src/primitives/mui-more.test.tsx +76 -0
- package/src/primitives/mui.test.tsx +134 -0
- package/src/primitives/transitions.tsx +120 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { chartColor, formatChartNumber, type ChartMargin } from "../lib/charts";
|
|
2
|
+
import {
|
|
3
|
+
featureId,
|
|
4
|
+
fitFeatures,
|
|
5
|
+
geoPath,
|
|
6
|
+
type GeoJsonFeatureCollection,
|
|
7
|
+
type GeoProjectionKind,
|
|
8
|
+
} from "../lib/geo";
|
|
9
|
+
import { ChartFrame, useChartHover } from "./chart-ui";
|
|
10
|
+
|
|
11
|
+
export type MapShapeDatum = {
|
|
12
|
+
id: string | number;
|
|
13
|
+
value: number;
|
|
14
|
+
label?: string;
|
|
15
|
+
color?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type MapChartProps = {
|
|
19
|
+
geoData: GeoJsonFeatureCollection;
|
|
20
|
+
series?: Array<{ data: MapShapeDatum[] }>;
|
|
21
|
+
idProperty?: string;
|
|
22
|
+
projection?: GeoProjectionKind;
|
|
23
|
+
height?: number;
|
|
24
|
+
width?: number;
|
|
25
|
+
colors?: string[];
|
|
26
|
+
hideLegend?: boolean;
|
|
27
|
+
margin?: ChartMargin;
|
|
28
|
+
className?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function MapChart({
|
|
32
|
+
geoData,
|
|
33
|
+
series,
|
|
34
|
+
idProperty = "id",
|
|
35
|
+
projection = "mercator",
|
|
36
|
+
height = 280,
|
|
37
|
+
width,
|
|
38
|
+
colors,
|
|
39
|
+
hideLegend,
|
|
40
|
+
margin,
|
|
41
|
+
className,
|
|
42
|
+
}: MapChartProps) {
|
|
43
|
+
const features = geoData.features ?? [];
|
|
44
|
+
const data = series?.[0]?.data ?? [];
|
|
45
|
+
const byId = new Map(data.map((item) => [String(item.id), item]));
|
|
46
|
+
const values = data.map((item) => item.value);
|
|
47
|
+
const min = values.length ? Math.min(...values) : 0;
|
|
48
|
+
const max = values.length ? Math.max(...values) : 1;
|
|
49
|
+
const { hover, setHover, clear } = useChartHover();
|
|
50
|
+
const legend = hideLegend
|
|
51
|
+
? undefined
|
|
52
|
+
: data.slice(0, 8).map((item, i) => ({
|
|
53
|
+
id: String(item.id),
|
|
54
|
+
label: item.label ?? String(item.id),
|
|
55
|
+
color: item.color ?? choroplethFill(item.value, min, max, i, colors),
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<ChartFrame
|
|
60
|
+
slot="map-chart"
|
|
61
|
+
className={className}
|
|
62
|
+
width={width}
|
|
63
|
+
height={height}
|
|
64
|
+
margin={margin ?? { top: 8, right: 8, bottom: 8, left: 8 }}
|
|
65
|
+
legend={legend}
|
|
66
|
+
hover={hover}
|
|
67
|
+
aria-label="Map chart"
|
|
68
|
+
>
|
|
69
|
+
{({ width: w, height: h, m }) => {
|
|
70
|
+
const pad = Math.min(m.left, m.right, m.top, m.bottom);
|
|
71
|
+
const project = fitFeatures(features, w, h, pad, projection);
|
|
72
|
+
return (
|
|
73
|
+
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
|
74
|
+
{features.map((feature, i) => {
|
|
75
|
+
if (!feature.geometry) return null;
|
|
76
|
+
const id = featureId(feature, idProperty) || String(i);
|
|
77
|
+
const datum = byId.get(id);
|
|
78
|
+
const label =
|
|
79
|
+
datum?.label ??
|
|
80
|
+
(feature.properties?.name != null ? String(feature.properties.name) : id);
|
|
81
|
+
const fill = datum
|
|
82
|
+
? (datum.color ?? choroplethFill(datum.value, min, max, i, colors))
|
|
83
|
+
: "color-mix(in srgb, var(--muted) 55%, transparent)";
|
|
84
|
+
const d = geoPath(feature.geometry, project);
|
|
85
|
+
if (!d) return null;
|
|
86
|
+
return (
|
|
87
|
+
<path
|
|
88
|
+
key={id}
|
|
89
|
+
d={d}
|
|
90
|
+
fill={fill}
|
|
91
|
+
className="spk-chart-map-region spk-chart-mark"
|
|
92
|
+
onMouseEnter={(event) =>
|
|
93
|
+
setHover({
|
|
94
|
+
x: event.nativeEvent.offsetX,
|
|
95
|
+
y: event.nativeEvent.offsetY,
|
|
96
|
+
title: label,
|
|
97
|
+
items: datum
|
|
98
|
+
? [
|
|
99
|
+
{
|
|
100
|
+
color: fill,
|
|
101
|
+
label: label,
|
|
102
|
+
value: formatChartNumber(datum.value),
|
|
103
|
+
},
|
|
104
|
+
]
|
|
105
|
+
: [{ color: fill, label: label, value: "—" }],
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
onMouseLeave={clear}
|
|
109
|
+
/>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</svg>
|
|
113
|
+
);
|
|
114
|
+
}}
|
|
115
|
+
</ChartFrame>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function choroplethFill(
|
|
120
|
+
value: number,
|
|
121
|
+
min: number,
|
|
122
|
+
max: number,
|
|
123
|
+
index: number,
|
|
124
|
+
colors?: string[],
|
|
125
|
+
): string {
|
|
126
|
+
const t = max === min ? 0.6 : (value - min) / (max - min);
|
|
127
|
+
const base = chartColor(index, colors);
|
|
128
|
+
const mix = Math.round(28 + t * 72);
|
|
129
|
+
return `color-mix(in srgb, ${base} ${mix}%, var(--muted))`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { ProfileHero } from "./ProfileHero";
|
|
5
|
+
|
|
6
|
+
describe("ProfileHero", () => {
|
|
7
|
+
it("renders name, alias, and a notched identity plate", () => {
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<ProfileHero name="Alex Chen" alias="Product designer" coverSeed="alex" bleed={false} />,
|
|
10
|
+
);
|
|
11
|
+
expect(screen.getByRole("heading", { name: "Alex Chen" })).toBeInTheDocument();
|
|
12
|
+
expect(screen.getByText("Product designer")).toBeInTheDocument();
|
|
13
|
+
expect(container.querySelector(".profile-identity")).toBeTruthy();
|
|
14
|
+
expect(container.querySelector(".profile-identity__plate")).toBeTruthy();
|
|
15
|
+
expect(container.querySelector("[data-slot='profile-hero']")).not.toHaveAttribute(
|
|
16
|
+
"data-cover-bleed",
|
|
17
|
+
);
|
|
18
|
+
expect(container.querySelector("[data-slot='profile-hero']")).toHaveClass("w-full");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps the avatar inert until onAvatarClick is provided", async () => {
|
|
22
|
+
const user = userEvent.setup();
|
|
23
|
+
const onAvatarClick = vi.fn();
|
|
24
|
+
const { rerender } = render(<ProfileHero name="Alex Chen" bleed={false} />);
|
|
25
|
+
expect(screen.getByRole("button", { name: "Alex Chen" })).toBeDisabled();
|
|
26
|
+
|
|
27
|
+
rerender(<ProfileHero name="Alex Chen" onAvatarClick={onAvatarClick} bleed={false} />);
|
|
28
|
+
const avatar = screen.getByRole("button", { name: "Alex Chen" });
|
|
29
|
+
expect(avatar).toBeEnabled();
|
|
30
|
+
await user.click(avatar);
|
|
31
|
+
expect(onAvatarClick).toHaveBeenCalledTimes(1);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -33,12 +33,14 @@ export type ProfileHeroProps = {
|
|
|
33
33
|
coverKind?: CoverPatternKind | null;
|
|
34
34
|
dark?: boolean;
|
|
35
35
|
warm?: boolean;
|
|
36
|
+
/** When false, keep the cover inside this frame (catalog previews). */
|
|
37
|
+
bleed?: boolean;
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
|
-
* Profile / entity hero: cover
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* Profile / entity hero: cover fills the frame, overlapping avatar notches
|
|
42
|
+
* the frosted identity plate. Stacked in a narrow column; left-notched once
|
|
43
|
+
* the plate is wide enough (container query, not viewport).
|
|
42
44
|
*/
|
|
43
45
|
export function ProfileHero({
|
|
44
46
|
name,
|
|
@@ -63,6 +65,7 @@ export function ProfileHero({
|
|
|
63
65
|
coverKind,
|
|
64
66
|
dark,
|
|
65
67
|
warm,
|
|
68
|
+
bleed = true,
|
|
66
69
|
}: ProfileHeroProps) {
|
|
67
70
|
const initials = getInitials(name);
|
|
68
71
|
const seed = coverSeed ?? name;
|
|
@@ -70,92 +73,106 @@ export function ProfileHero({
|
|
|
70
73
|
const ink = scene.headerInk;
|
|
71
74
|
|
|
72
75
|
return (
|
|
73
|
-
<div
|
|
76
|
+
<div
|
|
77
|
+
data-slot="profile-hero"
|
|
78
|
+
data-cover-bleed={bleed ? "true" : undefined}
|
|
79
|
+
className={cn(
|
|
80
|
+
"relative w-full",
|
|
81
|
+
bleed ? "overflow-visible" : "overflow-hidden rounded-[1.35rem] sm:rounded-[1.75rem]",
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
74
85
|
<div
|
|
75
|
-
className={cn(
|
|
76
|
-
|
|
77
|
-
coverClassName,
|
|
78
|
-
)}
|
|
86
|
+
className={cn("cover-media cover-media--profile", bleed && "sm:rounded-b-[2rem]")}
|
|
87
|
+
aria-hidden
|
|
79
88
|
>
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
{!hideImages && coverUrl ? (
|
|
90
|
+
<img src={coverUrl} alt="" className="absolute inset-0 size-full object-cover" />
|
|
91
|
+
) : (
|
|
92
|
+
<CoverPattern seed={seed} hue={coverHue} dark={dark} warm={warm} kind={coverKind} />
|
|
93
|
+
)}
|
|
94
|
+
<div className="absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-black/[0.04] to-transparent dark:from-black/12" />
|
|
95
|
+
<div className="absolute inset-x-0 bottom-0 h-[22%] bg-gradient-to-t from-black/[0.03] via-transparent to-transparent dark:from-black/10" />
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="relative z-10 flex flex-col">
|
|
99
|
+
<div
|
|
100
|
+
className={cn(
|
|
101
|
+
"relative min-h-[8.5rem] sm:min-h-[11rem] lg:min-h-[12rem]",
|
|
102
|
+
coverClassName,
|
|
85
103
|
)}
|
|
86
|
-
|
|
87
|
-
<div className="absolute inset-x-0
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
) : null}
|
|
104
|
+
>
|
|
105
|
+
<div className="absolute inset-x-0 top-0 z-30 flex items-start justify-between gap-2 p-3 sm:p-4">
|
|
106
|
+
<div>{topLeading}</div>
|
|
107
|
+
{topActions ? (
|
|
108
|
+
<div
|
|
109
|
+
data-cover-ink={ink}
|
|
110
|
+
className="cover-floating-shell flex items-center gap-1 p-1"
|
|
111
|
+
>
|
|
112
|
+
{topActions}
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
99
116
|
</div>
|
|
100
|
-
</div>
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
>
|
|
119
|
-
<span
|
|
120
|
-
className="flex items-center justify-center overflow-hidden rounded-full border-[3px] border-white bg-muted text-lg font-black leading-none text-primary shadow-[0_16px_40px_rgba(0,0,0,0.38)] dark:border-white/30"
|
|
121
|
-
style={{
|
|
122
|
-
width: "var(--profile-avatar-size)",
|
|
123
|
-
height: "var(--profile-avatar-size)",
|
|
124
|
-
}}
|
|
118
|
+
<div className="relative z-20 px-3 pb-4 sm:px-4 sm:pb-5 lg:px-6">
|
|
119
|
+
<div className={cn("profile-identity", identityClassName)}>
|
|
120
|
+
<div
|
|
121
|
+
aria-hidden
|
|
122
|
+
className={cn(
|
|
123
|
+
"pointer-events-none absolute inset-0 rounded-[1.35rem] profile-identity__plate",
|
|
124
|
+
coverPlateClass(ink),
|
|
125
|
+
)}
|
|
126
|
+
/>
|
|
127
|
+
<div className="profile-identity__body">
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
disabled={!onAvatarClick}
|
|
131
|
+
onClick={onAvatarClick}
|
|
132
|
+
aria-label={name}
|
|
133
|
+
className="profile-identity__avatar"
|
|
125
134
|
>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{avatarAccessory}
|
|
133
|
-
</button>
|
|
134
|
-
<div className="profile-identity__copy">
|
|
135
|
-
<div className="profile-identity__heading">
|
|
136
|
-
<h1
|
|
137
|
-
className={cn(
|
|
138
|
-
"truncate text-[1.15rem] font-black leading-tight tracking-tight sm:text-2xl",
|
|
139
|
-
coverTitleClass(ink),
|
|
140
|
-
)}
|
|
135
|
+
<span
|
|
136
|
+
className="flex items-center justify-center overflow-hidden rounded-full border-[3px] border-white bg-muted text-lg font-black leading-none text-primary shadow-[0_16px_40px_rgba(0,0,0,0.38)] dark:border-white/30"
|
|
137
|
+
style={{
|
|
138
|
+
width: "var(--profile-avatar-size)",
|
|
139
|
+
height: "var(--profile-avatar-size)",
|
|
140
|
+
}}
|
|
141
141
|
>
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
{!hideImages && avatarUrl ? (
|
|
143
|
+
<img src={avatarUrl} alt="" className="size-full object-cover" />
|
|
144
|
+
) : (
|
|
145
|
+
initials
|
|
146
|
+
)}
|
|
147
|
+
</span>
|
|
148
|
+
{avatarAccessory}
|
|
149
|
+
</button>
|
|
150
|
+
<div className="profile-identity__copy">
|
|
151
|
+
<div className="profile-identity__heading">
|
|
152
|
+
<h1
|
|
153
|
+
className={cn(
|
|
154
|
+
"truncate text-[1.15rem] font-black leading-tight tracking-tight sm:text-2xl",
|
|
155
|
+
coverTitleClass(ink),
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{name}
|
|
159
|
+
</h1>
|
|
160
|
+
{statusBadges}
|
|
161
|
+
</div>
|
|
162
|
+
{alias ? (
|
|
163
|
+
<p className={cn("mt-0.5 truncate text-sm font-medium", coverMutedClass(ink))}>
|
|
164
|
+
{alias}
|
|
165
|
+
</p>
|
|
166
|
+
) : null}
|
|
167
|
+
{metaLines ? (
|
|
168
|
+
<div className={cn("mt-2 space-y-1 text-sm", coverMutedClass(ink))}>{metaLines}</div>
|
|
169
|
+
) : null}
|
|
170
|
+
{stats ? <div className="mt-3">{stats}</div> : null}
|
|
171
|
+
{tags ? <div className="profile-identity__tags">{tags}</div> : null}
|
|
145
172
|
</div>
|
|
146
|
-
{alias ? (
|
|
147
|
-
<p className={cn("mt-0.5 truncate text-sm font-medium", coverMutedClass(ink))}>
|
|
148
|
-
{alias}
|
|
149
|
-
</p>
|
|
150
|
-
) : null}
|
|
151
|
-
{metaLines ? (
|
|
152
|
-
<div className={cn("mt-2 space-y-1 text-sm", coverMutedClass(ink))}>{metaLines}</div>
|
|
153
|
-
) : null}
|
|
154
|
-
{stats ? <div className="mt-3">{stats}</div> : null}
|
|
155
|
-
{tags ? <div className="profile-identity__tags">{tags}</div> : null}
|
|
156
173
|
</div>
|
|
174
|
+
{children ? <div className="relative z-10 px-3 pb-3 sm:px-6">{children}</div> : null}
|
|
157
175
|
</div>
|
|
158
|
-
{children ? <div className="relative z-10 px-3 pb-3 sm:px-6">{children}</div> : null}
|
|
159
176
|
</div>
|
|
160
177
|
</div>
|
|
161
178
|
</div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ScatterWebGLLayer } from "./ScatterWebGL";
|
|
4
|
+
|
|
5
|
+
describe("ScatterWebGLLayer", () => {
|
|
6
|
+
it("renders a canvas layer and reports when WebGL is unavailable", () => {
|
|
7
|
+
const onUnavailable = vi.fn();
|
|
8
|
+
const { container } = render(
|
|
9
|
+
<ScatterWebGLLayer
|
|
10
|
+
width={120}
|
|
11
|
+
height={80}
|
|
12
|
+
series={[{ data: [{ x: 1, y: 2 }] }]}
|
|
13
|
+
meta={[{ id: "a", label: "A", color: "#2563eb" }]}
|
|
14
|
+
hidden={new Set()}
|
|
15
|
+
xAt={(value) => value}
|
|
16
|
+
yAt={(value) => value}
|
|
17
|
+
sizeAt={(z) => z}
|
|
18
|
+
bubble={false}
|
|
19
|
+
onUnavailable={onUnavailable}
|
|
20
|
+
/>,
|
|
21
|
+
);
|
|
22
|
+
expect(container.querySelector('[data-slot="scatter-webgl"]')).toBeTruthy();
|
|
23
|
+
expect(onUnavailable).toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { createScatterGL, destroyScatterGL, drawScatterGL, type ScatterGL } from "../lib/webgl-scatter";
|
|
3
|
+
import type { ChartLegendItem } from "./chart-ui";
|
|
4
|
+
|
|
5
|
+
type ScatterSeriesLike = {
|
|
6
|
+
data: Array<{ x: number; y: number; z?: number }>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ScatterWebGLLayer({
|
|
10
|
+
width,
|
|
11
|
+
height,
|
|
12
|
+
series,
|
|
13
|
+
meta,
|
|
14
|
+
hidden,
|
|
15
|
+
xAt,
|
|
16
|
+
yAt,
|
|
17
|
+
sizeAt,
|
|
18
|
+
bubble,
|
|
19
|
+
onUnavailable,
|
|
20
|
+
}: {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
series: ScatterSeriesLike[];
|
|
24
|
+
meta: ChartLegendItem[];
|
|
25
|
+
hidden: Set<string>;
|
|
26
|
+
xAt: (value: number) => number;
|
|
27
|
+
yAt: (value: number) => number;
|
|
28
|
+
sizeAt: (z: number) => number;
|
|
29
|
+
bubble: boolean;
|
|
30
|
+
onUnavailable?: () => void;
|
|
31
|
+
}) {
|
|
32
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
33
|
+
const gpuRef = useRef<ScatterGL | null>(null);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const canvas = canvasRef.current;
|
|
37
|
+
if (!canvas) return;
|
|
38
|
+
const gpu = createScatterGL(canvas);
|
|
39
|
+
if (!gpu) {
|
|
40
|
+
onUnavailable?.();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
gpuRef.current = gpu;
|
|
44
|
+
return () => {
|
|
45
|
+
destroyScatterGL(gpu);
|
|
46
|
+
gpuRef.current = null;
|
|
47
|
+
};
|
|
48
|
+
}, [onUnavailable]);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const gpu = gpuRef.current;
|
|
52
|
+
const canvas = canvasRef.current;
|
|
53
|
+
if (!gpu || !canvas) return;
|
|
54
|
+
const batches = series.flatMap((item, s) => {
|
|
55
|
+
const info = meta[s]!;
|
|
56
|
+
if (hidden.has(info.id)) return [];
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
color: info.color,
|
|
60
|
+
positions: item.data.map((point) => ({
|
|
61
|
+
x: xAt(point.x),
|
|
62
|
+
y: yAt(point.y),
|
|
63
|
+
size: bubble ? sizeAt(point.z ?? 1) * 2 : 9,
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
});
|
|
68
|
+
drawScatterGL(gpu, canvas, width, height, batches);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<canvas
|
|
73
|
+
ref={canvasRef}
|
|
74
|
+
className="spk-chart-webgl"
|
|
75
|
+
data-slot="scatter-webgl"
|
|
76
|
+
aria-hidden
|
|
77
|
+
/>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { SiteFooter } from "./SiteFooter";
|
|
4
|
+
|
|
5
|
+
describe("SiteFooter", () => {
|
|
6
|
+
it("renders brand, copyright, links, and meta", () => {
|
|
7
|
+
render(
|
|
8
|
+
<SiteFooter
|
|
9
|
+
brand={<span>Acme</span>}
|
|
10
|
+
copyright="© 2026"
|
|
11
|
+
links={[{ href: "#about", label: "About" }]}
|
|
12
|
+
meta={<span>Powered by Spatika</span>}
|
|
13
|
+
/>,
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByText("Acme")).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText("© 2026")).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByRole("link", { name: "About" })).toHaveAttribute("href", "#about");
|
|
18
|
+
expect(screen.getByText("Powered by Spatika")).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("omits the nav when there are no links and uses renderLink when provided", () => {
|
|
22
|
+
const { rerender } = render(<SiteFooter brand={<span>Acme</span>} />);
|
|
23
|
+
expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
|
|
24
|
+
|
|
25
|
+
rerender(
|
|
26
|
+
<SiteFooter
|
|
27
|
+
links={[{ href: "/docs", label: "Docs" }]}
|
|
28
|
+
renderLink={(link, className) => (
|
|
29
|
+
<a href={link.href} className={className} data-custom="true">
|
|
30
|
+
{link.label}
|
|
31
|
+
</a>
|
|
32
|
+
)}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
expect(screen.getByRole("link", { name: "Docs" })).toHaveAttribute("data-custom", "true");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -18,7 +18,7 @@ const linkClass =
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* Marketing-site footer: brand, copyright, links, optional meta chips.
|
|
21
|
-
* Frosted surface bar with a primary hairline
|
|
21
|
+
* Frosted surface bar with a primary hairline.
|
|
22
22
|
*/
|
|
23
23
|
export function SiteFooter({
|
|
24
24
|
brand,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { SiteNav } from "./SiteNav";
|
|
5
|
+
|
|
6
|
+
describe("SiteNav", () => {
|
|
7
|
+
it("renders brand and links and opens the mobile menu", async () => {
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
render(
|
|
10
|
+
<SiteNav
|
|
11
|
+
brand={<a href="/">Brand</a>}
|
|
12
|
+
links={[
|
|
13
|
+
{ href: "#about", label: "About" },
|
|
14
|
+
{ href: "/blog", label: "Blog" },
|
|
15
|
+
]}
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText("Brand")).toBeInTheDocument();
|
|
19
|
+
expect(screen.getAllByText("About").length).toBeGreaterThan(0);
|
|
20
|
+
expect(screen.getByRole("navigation").className).toContain("spk-site-nav");
|
|
21
|
+
const toggle = screen.getByRole("button", { name: "Toggle menu" });
|
|
22
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
23
|
+
await user.click(toggle);
|
|
24
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("does not pin to the viewport when contained and closes the menu on Escape", async () => {
|
|
28
|
+
const user = userEvent.setup();
|
|
29
|
+
render(
|
|
30
|
+
<SiteNav
|
|
31
|
+
contained
|
|
32
|
+
brand={<a href="/">Brand</a>}
|
|
33
|
+
links={[{ href: "#about", label: "About" }]}
|
|
34
|
+
/>,
|
|
35
|
+
);
|
|
36
|
+
const nav = screen.getByRole("navigation");
|
|
37
|
+
expect(nav.className).toContain("absolute");
|
|
38
|
+
expect(nav.className).not.toMatch(/\bfixed\b/);
|
|
39
|
+
|
|
40
|
+
const toggle = screen.getByRole("button", { name: "Toggle menu" });
|
|
41
|
+
await user.click(toggle);
|
|
42
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
43
|
+
await user.keyboard("{Escape}");
|
|
44
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -14,6 +14,11 @@ export type SiteNavProps = {
|
|
|
14
14
|
className?: string;
|
|
15
15
|
/** Extra classes for the inner max-width row. */
|
|
16
16
|
innerClassName?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Pin the bar to a relatively positioned parent instead of the viewport.
|
|
19
|
+
* Use this in docs previews and embedded shells.
|
|
20
|
+
*/
|
|
21
|
+
contained?: boolean;
|
|
17
22
|
};
|
|
18
23
|
|
|
19
24
|
const desktopLinkClass =
|
|
@@ -24,7 +29,7 @@ const mobileLinkClass =
|
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Marketing-site navbar: fixed bar, brand, desktop links, and a 3-line mobile menu.
|
|
27
|
-
*
|
|
32
|
+
* Transparent at rest, frosted surface after scroll.
|
|
28
33
|
*/
|
|
29
34
|
export function SiteNav({
|
|
30
35
|
brand,
|
|
@@ -32,17 +37,19 @@ export function SiteNav({
|
|
|
32
37
|
renderLink,
|
|
33
38
|
className,
|
|
34
39
|
innerClassName,
|
|
40
|
+
contained = false,
|
|
35
41
|
}: SiteNavProps) {
|
|
36
|
-
const [scrolled, setScrolled] = useState(
|
|
42
|
+
const [scrolled, setScrolled] = useState(contained);
|
|
37
43
|
const [open, setOpen] = useState(false);
|
|
38
44
|
const menuId = useId();
|
|
39
45
|
|
|
40
46
|
useEffect(() => {
|
|
47
|
+
if (contained) return;
|
|
41
48
|
const onScroll = () => setScrolled(window.scrollY > 20);
|
|
42
49
|
onScroll();
|
|
43
50
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
44
51
|
return () => window.removeEventListener("scroll", onScroll);
|
|
45
|
-
}, []);
|
|
52
|
+
}, [contained]);
|
|
46
53
|
|
|
47
54
|
useEffect(() => {
|
|
48
55
|
if (!open) return;
|
|
@@ -74,7 +81,10 @@ export function SiteNav({
|
|
|
74
81
|
data-slot="site-nav"
|
|
75
82
|
data-scrolled={scrolled ? "true" : "false"}
|
|
76
83
|
className={cn(
|
|
77
|
-
"
|
|
84
|
+
"inset-x-0 top-0 transition-all duration-500",
|
|
85
|
+
contained
|
|
86
|
+
? "absolute z-10"
|
|
87
|
+
: "spk-site-nav fixed z-50",
|
|
78
88
|
scrolled
|
|
79
89
|
? "border-b border-border/90 bg-[color-mix(in_srgb,var(--background)_88%,transparent)] shadow-[0_8px_30px_rgba(15,13,18,0.06)] backdrop-blur-xl"
|
|
80
90
|
: "bg-transparent",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { StatCard } from "./StatCard";
|
|
4
|
+
|
|
5
|
+
describe("StatCard", () => {
|
|
6
|
+
it("renders label, value, and hint", () => {
|
|
7
|
+
render(<StatCard label="Users" value="144k" hint="+12 this week" />);
|
|
8
|
+
expect(screen.getByText("Users")).toBeInTheDocument();
|
|
9
|
+
expect(screen.getByText("144k")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("+12 this week")).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("renders an optional chart under the value", () => {
|
|
14
|
+
render(<StatCard label="Users" value="144k" chart={<span>trend</span>} />);
|
|
15
|
+
expect(screen.getByText("144k")).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText("trend")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("renders value-first metric layout", () => {
|
|
20
|
+
render(<StatCard variant="metric" label="Uptime" value="99.9%" />);
|
|
21
|
+
expect(screen.getByText("99.9%")).toBeInTheDocument();
|
|
22
|
+
expect(screen.getByText("Uptime")).toBeInTheDocument();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("omits the chart slot when none is provided", () => {
|
|
26
|
+
render(<StatCard label="Users" value="144k" />);
|
|
27
|
+
expect(screen.queryByText("trend")).not.toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText("144k")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|