@delacour/react-native-ui 0.1.0-alpha.3
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/README.md +113 -0
- package/package.json +125 -0
- package/src/components/accordion/AGENTS.md +192 -0
- package/src/components/accordion/accordion-content.tsx +125 -0
- package/src/components/accordion/accordion-description.tsx +17 -0
- package/src/components/accordion/accordion-indicator.tsx +104 -0
- package/src/components/accordion/accordion-item.tsx +78 -0
- package/src/components/accordion/accordion-title.tsx +18 -0
- package/src/components/accordion/accordion-trigger.tsx +148 -0
- package/src/components/accordion/accordion.context.tsx +153 -0
- package/src/components/accordion/accordion.tsx +253 -0
- package/src/components/accordion/accordion.types.ts +11 -0
- package/src/components/accordion/accordion.variants.test.ts +435 -0
- package/src/components/accordion/accordion.variants.ts +358 -0
- package/src/components/accordion/index.ts +45 -0
- package/src/components/badge/AGENTS.md +83 -0
- package/src/components/badge/badge-close-button.tsx +48 -0
- package/src/components/badge/badge-end-content.tsx +15 -0
- package/src/components/badge/badge-label.tsx +24 -0
- package/src/components/badge/badge-start-content.tsx +16 -0
- package/src/components/badge/badge.context.tsx +64 -0
- package/src/components/badge/badge.tsx +192 -0
- package/src/components/badge/badge.types.ts +10 -0
- package/src/components/badge/badge.variants.test.ts +269 -0
- package/src/components/badge/badge.variants.ts +238 -0
- package/src/components/badge/index.ts +17 -0
- package/src/components/bottom-sheet/AGENTS.md +232 -0
- package/src/components/bottom-sheet/bottom-sheet-background.tsx +35 -0
- package/src/components/bottom-sheet/bottom-sheet-close.tsx +60 -0
- package/src/components/bottom-sheet/bottom-sheet-container.tsx +245 -0
- package/src/components/bottom-sheet/bottom-sheet-content.tsx +66 -0
- package/src/components/bottom-sheet/bottom-sheet-description.tsx +29 -0
- package/src/components/bottom-sheet/bottom-sheet-footer.tsx +148 -0
- package/src/components/bottom-sheet/bottom-sheet-handle.tsx +47 -0
- package/src/components/bottom-sheet/bottom-sheet-overlay.tsx +180 -0
- package/src/components/bottom-sheet/bottom-sheet-portal.tsx +72 -0
- package/src/components/bottom-sheet/bottom-sheet-scroll-view.tsx +95 -0
- package/src/components/bottom-sheet/bottom-sheet-title.tsx +24 -0
- package/src/components/bottom-sheet/bottom-sheet-trigger.tsx +73 -0
- package/src/components/bottom-sheet/bottom-sheet.context.tsx +135 -0
- package/src/components/bottom-sheet/bottom-sheet.tsx +125 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.test.ts +276 -0
- package/src/components/bottom-sheet/bottom-sheet.variants.ts +199 -0
- package/src/components/bottom-sheet/index.ts +40 -0
- package/src/components/bottom-sheet/use-bottom-sheet-input.ts +125 -0
- package/src/components/button/AGENTS.md +217 -0
- package/src/components/button/button-end-content.tsx +15 -0
- package/src/components/button/button-group-separator.tsx +37 -0
- package/src/components/button/button-group-text.tsx +101 -0
- package/src/components/button/button-group.tsx +158 -0
- package/src/components/button/button-label.tsx +24 -0
- package/src/components/button/button-start-content.tsx +15 -0
- package/src/components/button/button.context.tsx +187 -0
- package/src/components/button/button.tsx +273 -0
- package/src/components/button/button.types.ts +10 -0
- package/src/components/button/button.variants.test.ts +811 -0
- package/src/components/button/button.variants.ts +480 -0
- package/src/components/button/index.ts +50 -0
- package/src/components/chart/AGENTS.md +210 -0
- package/src/components/chart/chart-area.tsx +93 -0
- package/src/components/chart/chart-bar.tsx +103 -0
- package/src/components/chart/chart-bars.tsx +75 -0
- package/src/components/chart/chart-candlestick.tsx +60 -0
- package/src/components/chart/chart-grid.tsx +33 -0
- package/src/components/chart/chart-legend.tsx +41 -0
- package/src/components/chart/chart-line.tsx +50 -0
- package/src/components/chart/chart-scatter.tsx +35 -0
- package/src/components/chart/chart-tooltip-dot.tsx +70 -0
- package/src/components/chart/chart-tooltip-x.tsx +121 -0
- package/src/components/chart/chart-tooltip-y.tsx +55 -0
- package/src/components/chart/chart-tooltip.tsx +133 -0
- package/src/components/chart/chart-x-axis.tsx +27 -0
- package/src/components/chart/chart-y-axis.tsx +21 -0
- package/src/components/chart/chart.context.tsx +127 -0
- package/src/components/chart/chart.tsx +448 -0
- package/src/components/chart/chart.types.ts +108 -0
- package/src/components/chart/chart.variants.test.ts +893 -0
- package/src/components/chart/chart.variants.ts +726 -0
- package/src/components/chart/index.ts +103 -0
- package/src/components/chart/pie-chart-center.tsx +40 -0
- package/src/components/chart/pie-chart-label.tsx +56 -0
- package/src/components/chart/pie-chart-slice.tsx +45 -0
- package/src/components/chart/pie-chart-tooltip.tsx +85 -0
- package/src/components/chart/pie-chart.context.tsx +77 -0
- package/src/components/chart/pie-chart.tsx +247 -0
- package/src/components/chart/use-chart-font.ts +25 -0
- package/src/components/chart/use-chart-palette.ts +38 -0
- package/src/components/checkbox/AGENTS.md +185 -0
- package/src/components/checkbox/checkbox-box.tsx +152 -0
- package/src/components/checkbox/checkbox-group.tsx +107 -0
- package/src/components/checkbox/checkbox-label.tsx +34 -0
- package/src/components/checkbox/checkbox.context.tsx +136 -0
- package/src/components/checkbox/checkbox.tsx +258 -0
- package/src/components/checkbox/checkbox.types.ts +14 -0
- package/src/components/checkbox/checkbox.variants.test.ts +634 -0
- package/src/components/checkbox/checkbox.variants.ts +484 -0
- package/src/components/checkbox/index.ts +42 -0
- package/src/components/field/AGENTS.md +100 -0
- package/src/components/field/field-content.tsx +24 -0
- package/src/components/field/field-description.tsx +29 -0
- package/src/components/field/field-error.tsx +61 -0
- package/src/components/field/field-group.tsx +22 -0
- package/src/components/field/field-label.tsx +33 -0
- package/src/components/field/field-legend.tsx +34 -0
- package/src/components/field/field-separator.tsx +81 -0
- package/src/components/field/field-set.tsx +22 -0
- package/src/components/field/field.context.tsx +78 -0
- package/src/components/field/field.tsx +144 -0
- package/src/components/field/field.types.ts +15 -0
- package/src/components/field/field.variants.test.ts +216 -0
- package/src/components/field/field.variants.ts +136 -0
- package/src/components/field/index.ts +21 -0
- package/src/components/icon/AGENTS.md +104 -0
- package/src/components/icon/icon.context.tsx +29 -0
- package/src/components/icon/icon.tsx +92 -0
- package/src/components/icon/icon.variants.test.ts +111 -0
- package/src/components/icon/icon.variants.ts +79 -0
- package/src/components/icon/index.ts +11 -0
- package/src/components/input/AGENTS.md +126 -0
- package/src/components/input/index.ts +27 -0
- package/src/components/input/input-group-decorator.tsx +84 -0
- package/src/components/input/input-group-prefix.tsx +16 -0
- package/src/components/input/input-group-suffix.tsx +15 -0
- package/src/components/input/input-group.tsx +140 -0
- package/src/components/input/input.context.tsx +87 -0
- package/src/components/input/input.tsx +184 -0
- package/src/components/input/input.types.ts +10 -0
- package/src/components/input/input.variants.test.ts +369 -0
- package/src/components/input/input.variants.ts +329 -0
- package/src/components/list-group/AGENTS.md +67 -0
- package/src/components/list-group/index.ts +20 -0
- package/src/components/list-group/list-group-item-content.tsx +10 -0
- package/src/components/list-group/list-group-item-description.tsx +12 -0
- package/src/components/list-group/list-group-item-prefix.tsx +30 -0
- package/src/components/list-group/list-group-item-suffix.tsx +55 -0
- package/src/components/list-group/list-group-item-title.tsx +17 -0
- package/src/components/list-group/list-group-item.tsx +87 -0
- package/src/components/list-group/list-group.context.tsx +65 -0
- package/src/components/list-group/list-group.tsx +116 -0
- package/src/components/list-group/list-group.types.ts +14 -0
- package/src/components/list-group/list-group.variants.test.ts +223 -0
- package/src/components/list-group/list-group.variants.ts +107 -0
- package/src/components/pressable/AGENTS.md +64 -0
- package/src/components/pressable/index.ts +9 -0
- package/src/components/pressable/pressable.tsx +256 -0
- package/src/components/pressable/pressable.variants.test.ts +99 -0
- package/src/components/pressable/pressable.variants.ts +63 -0
- package/src/components/provider/AGENTS.md +90 -0
- package/src/components/provider/index.ts +1 -0
- package/src/components/provider/provider.tsx +105 -0
- package/src/components/radio/AGENTS.md +247 -0
- package/src/components/radio/index.ts +35 -0
- package/src/components/radio/radio-group.tsx +126 -0
- package/src/components/radio/radio-indicator.tsx +91 -0
- package/src/components/radio/radio-label.tsx +39 -0
- package/src/components/radio/radio.context.tsx +132 -0
- package/src/components/radio/radio.tsx +215 -0
- package/src/components/radio/radio.variants.test.ts +581 -0
- package/src/components/radio/radio.variants.ts +272 -0
- package/src/components/screen/AGENTS.md +287 -0
- package/src/components/screen/index.ts +85 -0
- package/src/components/screen/screen-chat-list.tsx +485 -0
- package/src/components/screen/screen-content.tsx +69 -0
- package/src/components/screen/screen-debug.ts +43 -0
- package/src/components/screen/screen-error.tsx +61 -0
- package/src/components/screen/screen-flat-list.tsx +88 -0
- package/src/components/screen/screen-footer-background.tsx +60 -0
- package/src/components/screen/screen-footer.tsx +170 -0
- package/src/components/screen/screen-header.tsx +36 -0
- package/src/components/screen/screen-legend-list.tsx +105 -0
- package/src/components/screen/screen-list-component.tsx +23 -0
- package/src/components/screen/screen-loading.tsx +50 -0
- package/src/components/screen/screen-navbar-back-button.tsx +79 -0
- package/src/components/screen/screen-navbar-background.tsx +45 -0
- package/src/components/screen/screen-navbar-subtitle.tsx +26 -0
- package/src/components/screen/screen-navbar-title.tsx +31 -0
- package/src/components/screen/screen-navbar.tsx +154 -0
- package/src/components/screen/screen-root.tsx +38 -0
- package/src/components/screen/screen-scroll-area.tsx +116 -0
- package/src/components/screen/screen-scroll-shadow.tsx +186 -0
- package/src/components/screen/screen-section-list.tsx +81 -0
- package/src/components/screen/screen-view.tsx +59 -0
- package/src/components/screen/screen.context.tsx +167 -0
- package/src/components/screen/screen.tsx +114 -0
- package/src/components/screen/screen.types.ts +66 -0
- package/src/components/screen/screen.variants.test.ts +602 -0
- package/src/components/screen/screen.variants.ts +475 -0
- package/src/components/screen/use-screen-scroll-insets.ts +218 -0
- package/src/components/separator/AGENTS.md +30 -0
- package/src/components/separator/index.ts +8 -0
- package/src/components/separator/separator.tsx +85 -0
- package/src/components/slider/AGENTS.md +274 -0
- package/src/components/slider/index.ts +48 -0
- package/src/components/slider/slider-fill.tsx +68 -0
- package/src/components/slider/slider-output.tsx +55 -0
- package/src/components/slider/slider-thumb.tsx +161 -0
- package/src/components/slider/slider-track.tsx +233 -0
- package/src/components/slider/slider.context.tsx +118 -0
- package/src/components/slider/slider.tsx +321 -0
- package/src/components/slider/slider.types.ts +26 -0
- package/src/components/slider/slider.variants.test.ts +804 -0
- package/src/components/slider/slider.variants.ts +607 -0
- package/src/components/spinner/AGENTS.md +73 -0
- package/src/components/spinner/index.ts +17 -0
- package/src/components/spinner/spinner-arc.tsx +90 -0
- package/src/components/spinner/spinner-content.tsx +63 -0
- package/src/components/spinner/spinner.context.tsx +52 -0
- package/src/components/spinner/spinner.tsx +128 -0
- package/src/components/spinner/spinner.variants.test.ts +273 -0
- package/src/components/spinner/spinner.variants.ts +187 -0
- package/src/components/switch/AGENTS.md +213 -0
- package/src/components/switch/index.ts +42 -0
- package/src/components/switch/switch-content.tsx +111 -0
- package/src/components/switch/switch-end-content.tsx +18 -0
- package/src/components/switch/switch-start-content.tsx +20 -0
- package/src/components/switch/switch-thumb.tsx +102 -0
- package/src/components/switch/switch.context.tsx +79 -0
- package/src/components/switch/switch.tsx +423 -0
- package/src/components/switch/switch.types.ts +14 -0
- package/src/components/switch/switch.variants.test.ts +570 -0
- package/src/components/switch/switch.variants.ts +511 -0
- package/src/components/tabs/AGENTS.md +286 -0
- package/src/components/tabs/index.ts +69 -0
- package/src/components/tabs/tabs-content.tsx +65 -0
- package/src/components/tabs/tabs-indicator.tsx +97 -0
- package/src/components/tabs/tabs-label.tsx +62 -0
- package/src/components/tabs/tabs-list.tsx +139 -0
- package/src/components/tabs/tabs-pager.tsx +59 -0
- package/src/components/tabs/tabs-scroll-view.tsx +171 -0
- package/src/components/tabs/tabs-separator.tsx +73 -0
- package/src/components/tabs/tabs-trigger.tsx +210 -0
- package/src/components/tabs/tabs.context.tsx +294 -0
- package/src/components/tabs/tabs.tsx +435 -0
- package/src/components/tabs/tabs.types.ts +13 -0
- package/src/components/tabs/tabs.variants.test.ts +1020 -0
- package/src/components/tabs/tabs.variants.ts +672 -0
- package/src/components/text/AGENTS.md +95 -0
- package/src/components/text/index.ts +25 -0
- package/src/components/text/text.context.tsx +60 -0
- package/src/components/text/text.tsx +251 -0
- package/src/components/text/text.variants.test.ts +422 -0
- package/src/components/text/text.variants.ts +282 -0
- package/src/display-name.test.ts +145 -0
- package/src/docs.test.ts +98 -0
- package/src/expo/navigation-theme.tsx +58 -0
- package/src/hooks/use-controllable-state.ts +45 -0
- package/src/hooks/use-keyboard-state-sync.tsx +147 -0
- package/src/hooks/use-navigation-theme.ts +78 -0
- package/src/hooks/use-theme-color.ts +44 -0
- package/src/icons/central.ts +1 -0
- package/src/lib/cn.test.ts +136 -0
- package/src/lib/cn.ts +29 -0
- package/src/lib/color.test.ts +80 -0
- package/src/lib/color.ts +79 -0
- package/src/lib/compose-refs.test.ts +64 -0
- package/src/lib/compose-refs.ts +37 -0
- package/src/lib/keyboard-animation.test.ts +33 -0
- package/src/lib/keyboard-animation.ts +28 -0
- package/src/lib/merge-props.test.ts +82 -0
- package/src/lib/merge-props.ts +47 -0
- package/src/lib/navigation-theme.test.ts +49 -0
- package/src/lib/navigation-theme.ts +53 -0
- package/src/lib/slot.tsx +45 -0
- package/src/lib/tv.ts +20 -0
- package/src/styles/base.css +2 -0
- package/src/styles/geometry.test.ts +117 -0
- package/src/styles/index.css +3 -0
- package/src/styles/theme-tokens.test.ts +252 -0
- package/src/styles/theme.css +447 -0
- package/src/styles/tokens.css +132 -0
- package/src/styles/tokens.test.ts +181 -0
- package/src/styles/tokens.ts +86 -0
- package/src/uniwind-env.d.ts +1 -0
|
@@ -0,0 +1,893 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { formatDateTick } from "@delacour/react-native-charts/core";
|
|
3
|
+
import { declaredTokens, radiusMultiplier } from "../../styles/theme-tokens.test";
|
|
4
|
+
import type { ChartBarSpec, ChartConfig, ChartResolvedSeries } from "./chart.types";
|
|
5
|
+
import {
|
|
6
|
+
AREA_FILL_OPACITY,
|
|
7
|
+
AREA_STACKED_FILL_OPACITY,
|
|
8
|
+
applyChartColors,
|
|
9
|
+
BAR_RADIUS_MULTIPLIER,
|
|
10
|
+
BAR_RADIUS_STEP,
|
|
11
|
+
CANDLE_SENTIMENT_TOKENS,
|
|
12
|
+
CHART_MAX_TOKEN_SERIES,
|
|
13
|
+
CHART_SERIES_TOKENS,
|
|
14
|
+
CHART_SIZES,
|
|
15
|
+
chartAxisFontSize,
|
|
16
|
+
chartSeriesToken,
|
|
17
|
+
chartTickCount,
|
|
18
|
+
chartTooltipOffset,
|
|
19
|
+
chartVariants,
|
|
20
|
+
formatPieValue,
|
|
21
|
+
PIE_DEFAULT_INNER_RADIUS,
|
|
22
|
+
partitionChartColors,
|
|
23
|
+
pieInnerRadiusSpec,
|
|
24
|
+
pieLabelText,
|
|
25
|
+
pieSlicePercent,
|
|
26
|
+
resolveAreaFill,
|
|
27
|
+
resolveBarLayout,
|
|
28
|
+
resolveBarRadius,
|
|
29
|
+
resolveCategoryTickCount,
|
|
30
|
+
resolveChartKeys,
|
|
31
|
+
resolveChartSeries,
|
|
32
|
+
resolveDomainDefaults,
|
|
33
|
+
resolvePieSeries,
|
|
34
|
+
resolveStackedAreaKeys,
|
|
35
|
+
resolveTooltipRows,
|
|
36
|
+
resolveXValueFormat,
|
|
37
|
+
} from "./chart.variants";
|
|
38
|
+
|
|
39
|
+
/** Slots that may carry a text colour. Rule 1: colour goes on the Text. */
|
|
40
|
+
const LABEL_SLOTS = [
|
|
41
|
+
"legendLabel",
|
|
42
|
+
"tooltipHeading",
|
|
43
|
+
"tooltipName",
|
|
44
|
+
"tooltipValue",
|
|
45
|
+
"pieCenterValue",
|
|
46
|
+
"pieCenterLabel",
|
|
47
|
+
] as const;
|
|
48
|
+
|
|
49
|
+
describe("chartVariants", () => {
|
|
50
|
+
test("gives the frame a height token per size", () => {
|
|
51
|
+
expect(chartVariants({ size: "sm" }).frame()).toContain("h-chart-sm");
|
|
52
|
+
expect(chartVariants({ size: "md" }).frame()).toContain("h-chart-md");
|
|
53
|
+
expect(chartVariants({ size: "lg" }).frame()).toContain("h-chart-lg");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("defaults to the medium height", () => {
|
|
57
|
+
expect(chartVariants().frame()).toContain("h-chart-md");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("lets a caller's className win the merge", () => {
|
|
61
|
+
// Registering `chart-md` with tailwind-merge is what makes this work; an
|
|
62
|
+
// unregistered token would leave both classes and the winner undefined.
|
|
63
|
+
expect(chartVariants().frame({ className: "h-72" })).toContain("h-72");
|
|
64
|
+
expect(chartVariants().frame({ className: "h-72" })).not.toContain("h-chart-md");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("puts a text colour only on the slots that render text", () => {
|
|
68
|
+
// Rule 1: a View does not cascade colour in React Native, so a text colour
|
|
69
|
+
// on a container is a class that does nothing and hides the bug.
|
|
70
|
+
const slots = chartVariants();
|
|
71
|
+
const labelSlots = new Set<string>(LABEL_SLOTS);
|
|
72
|
+
for (const [name, slot] of Object.entries(slots)) {
|
|
73
|
+
if (typeof slot !== "function") continue;
|
|
74
|
+
const classes = slot();
|
|
75
|
+
if (typeof classes !== "string") continue;
|
|
76
|
+
if (/\btext-(foreground|muted-foreground|popover-foreground)\b/.test(classes)) {
|
|
77
|
+
expect(labelSlots.has(name)).toBe(true);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("paints the tooltip on the popover surface, not the card one", () => {
|
|
83
|
+
// A tooltip floats above content, which is what `popover` means here.
|
|
84
|
+
expect(chartVariants().tooltip()).toContain("bg-popover");
|
|
85
|
+
expect(chartVariants().tooltip()).toContain("border-border");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("chartSeriesToken", () => {
|
|
90
|
+
test("walks the ramp in order", () => {
|
|
91
|
+
expect(CHART_SERIES_TOKENS.map((_, index) => chartSeriesToken(index))).toEqual([...CHART_SERIES_TOKENS]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("cycles at five", () => {
|
|
95
|
+
expect(chartSeriesToken(5)).toBe("chart-1");
|
|
96
|
+
expect(chartSeriesToken(11)).toBe("chart-2");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("is total for any index", () => {
|
|
100
|
+
for (const index of [-3, 0, 1.7, 999]) {
|
|
101
|
+
expect(CHART_SERIES_TOKENS).toContain(chartSeriesToken(index));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("the ramp is real", () => {
|
|
107
|
+
// The highest-value assertion here. A renamed or half-declared chart token
|
|
108
|
+
// would otherwise show up as an invisible series on a device, in one theme.
|
|
109
|
+
test("every ramp token is declared in both light and dark", () => {
|
|
110
|
+
for (const variant of ["light", "dark"] as const) {
|
|
111
|
+
const declared = declaredTokens(variant);
|
|
112
|
+
for (const token of CHART_SERIES_TOKENS) {
|
|
113
|
+
expect(declared).toContain(token);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("resolveChartSeries", () => {
|
|
120
|
+
const config: ChartConfig = {
|
|
121
|
+
desktop: { label: "Desktop" },
|
|
122
|
+
mobile: { label: "Mobile", color: "chart-4" },
|
|
123
|
+
other: { label: "Other", color: "#EC4899" },
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
test("assigns the ramp by position when no colour is named", () => {
|
|
127
|
+
expect(resolveChartSeries({ a: { label: "A" }, b: { label: "B" } })).toEqual([
|
|
128
|
+
{ key: "a", label: "A", color: "chart-1" },
|
|
129
|
+
{ key: "b", label: "B", color: "chart-2" },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("an explicit token overrides the position", () => {
|
|
134
|
+
expect(resolveChartSeries(config)[1]?.color).toBe("chart-4");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a literal survives byte for byte", () => {
|
|
138
|
+
expect(resolveChartSeries(config)[2]?.color).toBe("#EC4899");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("falls back to the key when a series has no label", () => {
|
|
142
|
+
expect(resolveChartSeries({ visits: { label: "" } })[0]?.label).toBe("visits");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("yKeys narrows and reorders", () => {
|
|
146
|
+
expect(resolveChartSeries(config, ["other", "desktop"]).map((series) => series.key)).toEqual(["other", "desktop"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("ignores a yKey the config does not describe", () => {
|
|
150
|
+
expect(resolveChartSeries(config, ["desktop", "ghost"])).toHaveLength(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("cycles the ramp past five series", () => {
|
|
154
|
+
const many = Object.fromEntries(
|
|
155
|
+
Array.from({ length: 7 }, (_, index) => [`s${index}`, { label: `S${index}` }])
|
|
156
|
+
) as ChartConfig;
|
|
157
|
+
expect(resolveChartSeries(many)[5]?.color).toBe("chart-1");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("partitionChartColors", () => {
|
|
162
|
+
const series = (colors: string[]): ChartResolvedSeries[] =>
|
|
163
|
+
colors.map((color, index) => ({ key: `k${index}`, label: `L${index}`, color }));
|
|
164
|
+
|
|
165
|
+
test("pads the token list to exactly max, so the hook count never varies", () => {
|
|
166
|
+
expect(partitionChartColors(series(["chart-1", "chart-2"])).tokens).toHaveLength(CHART_MAX_TOKEN_SERIES);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("keeps literals out of the token list entirely", () => {
|
|
170
|
+
const partition = partitionChartColors(series(["#EC4899", "chart-2"]));
|
|
171
|
+
expect(partition.literals).toEqual({ k0: "#EC4899" });
|
|
172
|
+
expect(partition.tokens[0]).toBe("chart-2");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("treats a functional colour as a literal", () => {
|
|
176
|
+
expect(partitionChartColors(series(["rgb(1 2 3)"])).literals.k0).toBe("rgb(1 2 3)");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("throws by name past the cap rather than drawing a wrong colour", () => {
|
|
180
|
+
const tooMany = series(Array.from({ length: CHART_MAX_TOKEN_SERIES + 1 }, (_, index) => `token-${index}`));
|
|
181
|
+
expect(() => partitionChartColors(tooMany)).toThrow(/at most 8 distinct/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("a chart of literals is unbounded", () => {
|
|
185
|
+
const many = series(Array.from({ length: 20 }, () => "#123456"));
|
|
186
|
+
expect(() => partitionChartColors(many)).not.toThrow();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("resolves a distinct token once, however many series wear it", () => {
|
|
190
|
+
const partition = partitionChartColors(series(["chart-1", "chart-2", "chart-1", "chart-2", "chart-1"]));
|
|
191
|
+
expect(partition.tokens.slice(0, 2)).toEqual(["chart-1", "chart-2"]);
|
|
192
|
+
expect(partition.tokens[2]).toBe(CHART_SERIES_TOKENS[0]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("two series on one token share a slot", () => {
|
|
196
|
+
const partition = partitionChartColors(series(["chart-1", "chart-3", "chart-1"]));
|
|
197
|
+
expect(partition.slotOf["chart-1"]).toBe(0);
|
|
198
|
+
expect(partition.slotOf["chart-3"]).toBe(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("twenty series on the default ramp fit, because the ramp dedupes to five", () => {
|
|
202
|
+
// A pie of twenty slices, or nine lines, walks the five-token ramp and
|
|
203
|
+
// needs five slots — not twenty.
|
|
204
|
+
const many = series(Array.from({ length: 20 }, (_, index) => chartSeriesToken(index)));
|
|
205
|
+
const partition = partitionChartColors(many);
|
|
206
|
+
expect(() => partitionChartColors(many)).not.toThrow();
|
|
207
|
+
expect(Object.keys(partition.slotOf)).toHaveLength(CHART_SERIES_TOKENS.length);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("nine distinct tokens still throw", () => {
|
|
211
|
+
const nine = series([
|
|
212
|
+
"chart-1",
|
|
213
|
+
"chart-2",
|
|
214
|
+
"chart-3",
|
|
215
|
+
"chart-4",
|
|
216
|
+
"chart-5",
|
|
217
|
+
"primary",
|
|
218
|
+
"success",
|
|
219
|
+
"info",
|
|
220
|
+
"warning",
|
|
221
|
+
]);
|
|
222
|
+
expect(() => partitionChartColors(nine)).toThrow(/at most 8 distinct/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("a literal never takes a slot", () => {
|
|
226
|
+
const partition = partitionChartColors(series(["#EC4899", "rgb(1 2 3)", "chart-2"]));
|
|
227
|
+
expect(partition.slotOf["#EC4899"]).toBeUndefined();
|
|
228
|
+
expect(partition.slotOf["rgb(1 2 3)"]).toBeUndefined();
|
|
229
|
+
expect(partition.slotOf["chart-2"]).toBe(0);
|
|
230
|
+
expect(Object.keys(partition.slotOf)).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("slots follow first appearance, with literals interleaved", () => {
|
|
234
|
+
const partition = partitionChartColors(series(["chart-4", "#000", "chart-2", "chart-4", "#fff", "chart-1"]));
|
|
235
|
+
expect(partition.tokens.slice(0, 3)).toEqual(["chart-4", "chart-2", "chart-1"]);
|
|
236
|
+
expect(partition.slotOf).toEqual({ "chart-4": 0, "chart-2": 1, "chart-1": 2 });
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("applyChartColors", () => {
|
|
241
|
+
test("reassembles resolved values in the original order", () => {
|
|
242
|
+
const series: ChartResolvedSeries[] = [
|
|
243
|
+
{ key: "a", label: "A", color: "chart-1" },
|
|
244
|
+
{ key: "b", label: "B", color: "#EC4899" },
|
|
245
|
+
{ key: "c", label: "C", color: "chart-2" },
|
|
246
|
+
];
|
|
247
|
+
const partition = partitionChartColors(series);
|
|
248
|
+
const resolved = ["oklch(1 0 0)", "oklch(2 0 0)", ...Array(6).fill("unused")];
|
|
249
|
+
expect(applyChartColors(series, partition, resolved).map((entry) => entry.color)).toEqual([
|
|
250
|
+
"oklch(1 0 0)",
|
|
251
|
+
"#EC4899",
|
|
252
|
+
"oklch(2 0 0)",
|
|
253
|
+
]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("keeps the token name when the theme emits no value for it", () => {
|
|
257
|
+
const series: ChartResolvedSeries[] = [{ key: "a", label: "A", color: "chart-1" }];
|
|
258
|
+
expect(applyChartColors(series, partitionChartColors(series), [undefined])[0]?.color).toBe("chart-1");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("series sharing a token read the same resolved slot", () => {
|
|
262
|
+
const series: ChartResolvedSeries[] = [
|
|
263
|
+
{ key: "a", label: "A", color: "chart-1" },
|
|
264
|
+
{ key: "b", label: "B", color: "chart-2" },
|
|
265
|
+
{ key: "c", label: "C", color: "chart-1" },
|
|
266
|
+
];
|
|
267
|
+
const partition = partitionChartColors(series);
|
|
268
|
+
const resolved = ["oklch(1 0 0)", "oklch(2 0 0)", ...Array(6).fill("unused")];
|
|
269
|
+
expect(applyChartColors(series, partition, resolved).map((entry) => entry.color)).toEqual([
|
|
270
|
+
"oklch(1 0 0)",
|
|
271
|
+
"oklch(2 0 0)",
|
|
272
|
+
"oklch(1 0 0)",
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("preserves order across a ramp that wraps", () => {
|
|
277
|
+
const series = Array.from({ length: 7 }, (_, index) => ({
|
|
278
|
+
key: `k${index}`,
|
|
279
|
+
label: `L${index}`,
|
|
280
|
+
color: chartSeriesToken(index),
|
|
281
|
+
}));
|
|
282
|
+
const partition = partitionChartColors(series);
|
|
283
|
+
const resolved = CHART_SERIES_TOKENS.map((token) => `resolved(${token})`);
|
|
284
|
+
expect(applyChartColors(series, partition, [...resolved, "x", "y", "z"]).map((entry) => entry.color)).toEqual([
|
|
285
|
+
"resolved(chart-1)",
|
|
286
|
+
"resolved(chart-2)",
|
|
287
|
+
"resolved(chart-3)",
|
|
288
|
+
"resolved(chart-4)",
|
|
289
|
+
"resolved(chart-5)",
|
|
290
|
+
"resolved(chart-1)",
|
|
291
|
+
"resolved(chart-2)",
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("chartTooltipOffset", () => {
|
|
297
|
+
const frame = { frameWidth: 300, frameHeight: 200, width: 80, height: 40 };
|
|
298
|
+
|
|
299
|
+
test("sits to the right of the cursor with room to spare", () => {
|
|
300
|
+
expect(chartTooltipOffset({ ...frame, x: 50, y: 100 }).x).toBe(62);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("flips to the left near the right edge", () => {
|
|
304
|
+
expect(chartTooltipOffset({ ...frame, x: 280, y: 100 }).x).toBe(188);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("clamps rather than leaving the frame at either edge", () => {
|
|
308
|
+
expect(chartTooltipOffset({ ...frame, x: 2, y: 100 }).x).toBeGreaterThanOrEqual(0);
|
|
309
|
+
const right = chartTooltipOffset({ ...frame, x: 299, y: 100 }).x;
|
|
310
|
+
expect(right).toBeGreaterThanOrEqual(0);
|
|
311
|
+
expect(right + frame.width).toBeLessThanOrEqual(frame.frameWidth);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("centres vertically on the cursor and clamps at both ends", () => {
|
|
315
|
+
expect(chartTooltipOffset({ ...frame, x: 50, y: 100 }).y).toBe(80);
|
|
316
|
+
expect(chartTooltipOffset({ ...frame, x: 50, y: 0 }).y).toBe(0);
|
|
317
|
+
expect(chartTooltipOffset({ ...frame, x: 50, y: 200 }).y).toBe(160);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("returns finite numbers for a frame with no size", () => {
|
|
321
|
+
// The first frame, before onLayout. A NaN here freezes the tooltip.
|
|
322
|
+
const offset = chartTooltipOffset({ x: 0, y: 0, width: 0, height: 0, frameWidth: 0, frameHeight: 0 });
|
|
323
|
+
expect(Number.isFinite(offset.x)).toBe(true);
|
|
324
|
+
expect(Number.isFinite(offset.y)).toBe(true);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("survives a non-finite measurement", () => {
|
|
328
|
+
const offset = chartTooltipOffset({ ...frame, x: 10, y: 10, width: Number.NaN });
|
|
329
|
+
expect(Number.isFinite(offset.x)).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("size-derived numbers", () => {
|
|
334
|
+
test("the axis font and tick count both grow with the chart", () => {
|
|
335
|
+
const fonts = CHART_SIZES.map(chartAxisFontSize);
|
|
336
|
+
const ticks = CHART_SIZES.map(chartTickCount);
|
|
337
|
+
expect(fonts).toEqual([...fonts].sort((a, b) => a - b));
|
|
338
|
+
expect(ticks).toEqual([...ticks].sort((a, b) => a - b));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("a small chart asks for fewer ticks, because its labels would collide", () => {
|
|
342
|
+
expect(chartTickCount("sm")).toBeLessThan(chartTickCount("lg"));
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe("resolveXValueFormat", () => {
|
|
347
|
+
const dated = (days: number[]) => days.map((day) => ({ at: new Date(2026, 0, day), v: day }));
|
|
348
|
+
|
|
349
|
+
test("formats a Date as a date, not as an RFC timestamp", () => {
|
|
350
|
+
// `String(new Date())` is "Tue Jan 20 2026 00:00:00 GMT+0000" — a full
|
|
351
|
+
// timestamp beside a two-digit value, in a box sized for neither.
|
|
352
|
+
const format = resolveXValueFormat(dated([1, 15, 30]), "at");
|
|
353
|
+
expect(format({ at: new Date(2026, 0, 20) })).toBe("20 Jan");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("matches the axis at the same granularity", () => {
|
|
357
|
+
// The heading has to read `20 Jan` directly above an axis reading
|
|
358
|
+
// `18 Jan`, not as a second rendering of the same instant.
|
|
359
|
+
const rows = dated([1, 15, 30]);
|
|
360
|
+
const span = (rows.at(-1)?.at.getTime() ?? 0) - (rows[0]?.at.getTime() ?? 0);
|
|
361
|
+
const format = resolveXValueFormat(rows, "at");
|
|
362
|
+
for (const row of rows) {
|
|
363
|
+
expect(format(row)).toBe(formatDateTick(row.at.getTime(), span));
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("uses a clock for an intraday span", () => {
|
|
368
|
+
const rows = [{ at: new Date(2026, 0, 1, 9, 0) }, { at: new Date(2026, 0, 1, 15, 30) }];
|
|
369
|
+
expect(resolveXValueFormat(rows, "at")(rows[1] as (typeof rows)[number])).toBe("15:30");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("uses a year for a multi-year span", () => {
|
|
373
|
+
const rows = [{ at: new Date(2018, 0, 1) }, { at: new Date(2026, 0, 1) }];
|
|
374
|
+
expect(resolveXValueFormat(rows, "at")(rows[1] as (typeof rows)[number])).toBe("2026");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("shows a date rather than a clock when a single row gives no span", () => {
|
|
378
|
+
const rows = [{ at: new Date(2026, 0, 20) }];
|
|
379
|
+
expect(resolveXValueFormat(rows, "at")(rows[0] as (typeof rows)[number])).toBe("20 Jan");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("strips float noise from a numeric x", () => {
|
|
383
|
+
expect(resolveXValueFormat([{ t: 0 }], "t")({ t: 0.1 + 0.2 })).toBe("0.3");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("passes a label through untouched", () => {
|
|
387
|
+
expect(resolveXValueFormat([{ m: "Jan" }], "m")({ m: "Jan" })).toBe("Jan");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("returns an empty heading for a missing field rather than 'undefined'", () => {
|
|
391
|
+
expect(resolveXValueFormat([{ m: "Jan" }], "m")({})).toBe("");
|
|
392
|
+
expect(resolveXValueFormat(dated([1, 2]), "at")({})).toBe("");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("resolvePieSeries", () => {
|
|
397
|
+
const rows = [
|
|
398
|
+
{ browser: "chrome", visitors: 275 },
|
|
399
|
+
{ browser: "safari", visitors: 200 },
|
|
400
|
+
{ browser: "firefox", visitors: 187 },
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
test("walks the ramp in data order", () => {
|
|
404
|
+
const { series } = resolvePieSeries(rows, "browser", "visitors", {});
|
|
405
|
+
expect(series.map((entry) => entry.color)).toEqual(["chart-1", "chart-2", "chart-3"]);
|
|
406
|
+
expect(series.map((entry) => entry.key)).toEqual(["chrome", "safari", "firefox"]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("an unnamed datum keeps its name as the label and its ramp slot", () => {
|
|
410
|
+
const { series } = resolvePieSeries(rows, "browser", "visitors", { safari: { label: "Safari" } });
|
|
411
|
+
expect(series[0]?.label).toBe("chrome");
|
|
412
|
+
expect(series[1]?.label).toBe("Safari");
|
|
413
|
+
expect(series[2]?.color).toBe("chart-3");
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("a config entry overrides the colour by name without moving the ramp", () => {
|
|
417
|
+
const { series } = resolvePieSeries(rows, "browser", "visitors", { safari: { label: "Safari", color: "#F00" } });
|
|
418
|
+
expect(series.map((entry) => entry.color)).toEqual(["chart-1", "#F00", "chart-3"]);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("sums the values", () => {
|
|
422
|
+
const { values, total } = resolvePieSeries(rows, "browser", "visitors", {});
|
|
423
|
+
expect(values).toEqual([275, 200, 187]);
|
|
424
|
+
expect(total).toBe(662);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("drops a negative, NaN, null or missing value", () => {
|
|
428
|
+
const { series, values, total } = resolvePieSeries(
|
|
429
|
+
[
|
|
430
|
+
{ name: "a", value: 10 },
|
|
431
|
+
{ name: "b", value: -5 },
|
|
432
|
+
{ name: "c", value: Number.NaN },
|
|
433
|
+
{ name: "d", value: null },
|
|
434
|
+
{ name: "e" },
|
|
435
|
+
{ name: "f", value: "7" },
|
|
436
|
+
],
|
|
437
|
+
"name",
|
|
438
|
+
"value",
|
|
439
|
+
{}
|
|
440
|
+
);
|
|
441
|
+
expect(series.map((entry) => entry.key)).toEqual(["a", "f"]);
|
|
442
|
+
expect(values).toEqual([10, 7]);
|
|
443
|
+
expect(total).toBe(17);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a zero stays, so its legend row and its slice index line up", () => {
|
|
447
|
+
const { series, values } = resolvePieSeries(
|
|
448
|
+
[
|
|
449
|
+
{ name: "a", value: 0 },
|
|
450
|
+
{ name: "b", value: 4 },
|
|
451
|
+
],
|
|
452
|
+
"name",
|
|
453
|
+
"value",
|
|
454
|
+
{}
|
|
455
|
+
);
|
|
456
|
+
expect(series.map((entry) => entry.key)).toEqual(["a", "b"]);
|
|
457
|
+
expect(values).toEqual([0, 4]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("keys a numeric or missing name as a string", () => {
|
|
461
|
+
const { series } = resolvePieSeries([{ id: 2024, value: 1 }, { value: 2 }], "id", "value", {
|
|
462
|
+
2024: { label: "This year" },
|
|
463
|
+
});
|
|
464
|
+
expect(series[0]?.key).toBe("2024");
|
|
465
|
+
expect(series[0]?.label).toBe("This year");
|
|
466
|
+
expect(series[1]?.key).toBe("1");
|
|
467
|
+
expect(series[1]?.label).toBe("1");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("two rows with one name keep distinct keys", () => {
|
|
471
|
+
const { series } = resolvePieSeries(
|
|
472
|
+
[
|
|
473
|
+
{ name: "a", value: 1 },
|
|
474
|
+
{ name: "a", value: 2 },
|
|
475
|
+
],
|
|
476
|
+
"name",
|
|
477
|
+
"value",
|
|
478
|
+
{}
|
|
479
|
+
);
|
|
480
|
+
expect(new Set(series.map((entry) => entry.key)).size).toBe(2);
|
|
481
|
+
expect(series.map((entry) => entry.label)).toEqual(["a", "a"]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("empty data is an empty series with a zero total", () => {
|
|
485
|
+
expect(resolvePieSeries([], "name", "value", {})).toEqual({ series: [], values: [], total: 0 });
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
describe("formatPieValue", () => {
|
|
490
|
+
test("groups digits in threes, the way a headline count is written", () => {
|
|
491
|
+
expect(formatPieValue(1240)).toBe("1,240");
|
|
492
|
+
expect(formatPieValue(1125)).toBe("1,125");
|
|
493
|
+
expect(formatPieValue(1234567)).toBe("1,234,567");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("leaves a short number alone", () => {
|
|
497
|
+
expect(formatPieValue(0)).toBe("0");
|
|
498
|
+
expect(formatPieValue(980)).toBe("980");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("keeps a fraction, ungrouped, after the point", () => {
|
|
502
|
+
expect(formatPieValue(1234.5)).toBe("1,234.5");
|
|
503
|
+
expect(formatPieValue(0.1 + 0.2)).toBe("0.3");
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("keeps the sign and prints nothing for a non-number", () => {
|
|
507
|
+
expect(formatPieValue(-1240)).toBe("-1,240");
|
|
508
|
+
expect(formatPieValue(Number.NaN)).toBe("");
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe("pieSlicePercent", () => {
|
|
513
|
+
test("is the value's share of the total, in percent", () => {
|
|
514
|
+
expect(pieSlicePercent(25, 100)).toBe(25);
|
|
515
|
+
expect(pieSlicePercent(1, 3)).toBeCloseTo(33.333, 2);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test("is zero when the total is zero", () => {
|
|
519
|
+
expect(pieSlicePercent(5, 0)).toBe(0);
|
|
520
|
+
expect(pieSlicePercent(0, 0)).toBe(0);
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe("pieLabelText", () => {
|
|
525
|
+
const slice = { label: "Chrome", value: 275.5, fraction: 0.4162 };
|
|
526
|
+
|
|
527
|
+
test("prints a percentage by default, rounded to a whole number", () => {
|
|
528
|
+
expect(pieLabelText(undefined, slice)).toBe("42%");
|
|
529
|
+
expect(pieLabelText("percent", slice)).toBe("42%");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("prints the value through the tick formatter", () => {
|
|
533
|
+
expect(pieLabelText("value", slice)).toBe("275.5");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
test("prints the label", () => {
|
|
537
|
+
expect(pieLabelText("label", slice)).toBe("Chrome");
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("defers to a function", () => {
|
|
541
|
+
expect(pieLabelText((current) => `${current.label}!`, slice)).toBe("Chrome!");
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
describe("pieInnerRadiusSpec", () => {
|
|
546
|
+
test("turns a fraction into the engine's percentage", () => {
|
|
547
|
+
expect(pieInnerRadiusSpec(0.6)).toBe("60%");
|
|
548
|
+
expect(pieInnerRadiusSpec(0.605)).toBe("61%");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("defaults to a solid pie", () => {
|
|
552
|
+
expect(PIE_DEFAULT_INNER_RADIUS).toBe(0);
|
|
553
|
+
expect(pieInnerRadiusSpec(PIE_DEFAULT_INNER_RADIUS)).toBe("0%");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("clamps to the unit interval and ignores a non-number", () => {
|
|
557
|
+
expect(pieInnerRadiusSpec(1.4)).toBe("100%");
|
|
558
|
+
expect(pieInnerRadiusSpec(-0.2)).toBe("0%");
|
|
559
|
+
expect(pieInnerRadiusSpec(Number.NaN)).toBe("0%");
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe("pie slots", () => {
|
|
564
|
+
test("centres the hole content over the frame without taking touches", () => {
|
|
565
|
+
expect(chartVariants().pieCenter()).toContain("absolute");
|
|
566
|
+
expect(chartVariants().pieCenter()).toContain("items-center");
|
|
567
|
+
expect(chartVariants().pieCenter()).toContain("justify-center");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("the centre value is on the type scale, the caption is muted", () => {
|
|
571
|
+
expect(chartVariants().pieCenterValue()).toContain("text-foreground");
|
|
572
|
+
expect(chartVariants().pieCenterLabel()).toContain("text-muted-foreground");
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
describe("resolveBarLayout", () => {
|
|
577
|
+
const bar = (yKey: string, stackId?: string): ChartBarSpec => (stackId === undefined ? { yKey } : { yKey, stackId });
|
|
578
|
+
|
|
579
|
+
test("no bars is no layout", () => {
|
|
580
|
+
expect(resolveBarLayout([])).toEqual({ mode: "none", keys: [], slotOf: {} });
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("one bar stands alone", () => {
|
|
584
|
+
const layout = resolveBarLayout([bar("a")]);
|
|
585
|
+
expect(layout.mode).toBe("single");
|
|
586
|
+
expect(layout.keys).toEqual(["a"]);
|
|
587
|
+
expect(layout.slotOf.a).toEqual({ groupIndex: 0, groupCount: 1 });
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("sibling bars group side by side, in placement order", () => {
|
|
591
|
+
const layout = resolveBarLayout([bar("a"), bar("b"), bar("c")]);
|
|
592
|
+
expect(layout.mode).toBe("grouped");
|
|
593
|
+
expect(layout.keys).toEqual(["a", "b", "c"]);
|
|
594
|
+
expect(layout.slotOf.b).toEqual({ groupIndex: 1, groupCount: 3 });
|
|
595
|
+
expect(layout.stackId).toBeUndefined();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("bars sharing a stackId stack, bottom first", () => {
|
|
599
|
+
const layout = resolveBarLayout([bar("a", "t"), bar("b", "t")]);
|
|
600
|
+
expect(layout.mode).toBe("stacked");
|
|
601
|
+
expect(layout.stackId).toBe("t");
|
|
602
|
+
expect(layout.slotOf.a).toEqual({ groupIndex: 0, groupCount: 1, stackIndex: 0 });
|
|
603
|
+
expect(layout.slotOf.b).toEqual({ groupIndex: 0, groupCount: 1, stackIndex: 1 });
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("a lone bar with a stackId is still a stack of one, so its key reaches stackKeys", () => {
|
|
607
|
+
expect(resolveBarLayout([bar("a", "t")]).mode).toBe("stacked");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("two stackIds throw by name — one stack per chart", () => {
|
|
611
|
+
expect(() => resolveBarLayout([bar("a", "t"), bar("b", "u")])).toThrow(/one stack per chart/);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("a stack beside a loose bar throws the same way", () => {
|
|
615
|
+
expect(() => resolveBarLayout([bar("a", "t"), bar("b")])).toThrow(/one stack per chart/);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("the same yKey twice throws rather than drawing it twice", () => {
|
|
619
|
+
expect(() => resolveBarLayout([bar("a"), bar("a")])).toThrow(/yKey="a".*twice/);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
describe("resolveStackedAreaKeys", () => {
|
|
624
|
+
const none = resolveBarLayout([]);
|
|
625
|
+
|
|
626
|
+
test("areas without a stackId stack nothing", () => {
|
|
627
|
+
expect(resolveStackedAreaKeys([{ yKey: "a" }, { yKey: "b" }], none)).toEqual([]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("areas sharing a stackId stack in placement order", () => {
|
|
631
|
+
expect(
|
|
632
|
+
resolveStackedAreaKeys(
|
|
633
|
+
[
|
|
634
|
+
{ yKey: "a", stackId: "s" },
|
|
635
|
+
{ yKey: "b", stackId: "s" },
|
|
636
|
+
],
|
|
637
|
+
none
|
|
638
|
+
)
|
|
639
|
+
).toEqual(["a", "b"]);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("a loose area beside a stacked one is left out of the stack", () => {
|
|
643
|
+
expect(resolveStackedAreaKeys([{ yKey: "a", stackId: "s" }, { yKey: "b" }], none)).toEqual(["a"]);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
test("two area stackIds throw by name", () => {
|
|
647
|
+
expect(() =>
|
|
648
|
+
resolveStackedAreaKeys(
|
|
649
|
+
[
|
|
650
|
+
{ yKey: "a", stackId: "s" },
|
|
651
|
+
{ yKey: "b", stackId: "t" },
|
|
652
|
+
],
|
|
653
|
+
none
|
|
654
|
+
)
|
|
655
|
+
).toThrow(/one stack per chart/);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("an area stack beside a bar stack throws — the chart has one stack", () => {
|
|
659
|
+
const bars = resolveBarLayout([
|
|
660
|
+
{ yKey: "x", stackId: "t" },
|
|
661
|
+
{ yKey: "y", stackId: "t" },
|
|
662
|
+
]);
|
|
663
|
+
expect(() => resolveStackedAreaKeys([{ yKey: "a", stackId: "s" }], bars)).toThrow(/one stack per chart/);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("unstacked areas beside a bar stack are fine", () => {
|
|
667
|
+
const bars = resolveBarLayout([{ yKey: "x", stackId: "t" }]);
|
|
668
|
+
expect(resolveStackedAreaKeys([{ yKey: "a" }], bars)).toEqual([]);
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
describe("resolveChartKeys", () => {
|
|
673
|
+
test("is the series keys when there is no candlestick", () => {
|
|
674
|
+
expect(resolveChartKeys(["a", "b"], null)).toEqual(["a", "b"]);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test("appends the candle fields the series do not already name", () => {
|
|
678
|
+
const keys = resolveChartKeys(["close"], { open: "open", high: "high", low: "low", close: "close" });
|
|
679
|
+
expect(keys).toEqual(["close", "open", "high", "low"]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("never repeats a key", () => {
|
|
683
|
+
const keys = resolveChartKeys(["a"], { open: "o", high: "o", low: "l", close: "a" });
|
|
684
|
+
expect(keys).toEqual(["a", "o", "l"]);
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
describe("bar corners", () => {
|
|
689
|
+
// Restated in TypeScript for the reason `checkbox.variants.ts` restates its
|
|
690
|
+
// own: the corner scale is `@theme inline`, so only `--radius` reaches the
|
|
691
|
+
// runtime. Pinned against `tokens.css` so retuning the scale fails here.
|
|
692
|
+
test("multiplies --radius by what tokens.css says the step multiplies it by", () => {
|
|
693
|
+
for (const size of CHART_SIZES) {
|
|
694
|
+
expect(BAR_RADIUS_MULTIPLIER[size]).toBe(radiusMultiplier(BAR_RADIUS_STEP[size]));
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("a smaller chart wears a tighter corner, never a looser one", () => {
|
|
699
|
+
const multipliers = CHART_SIZES.map((size) => BAR_RADIUS_MULTIPLIER[size]);
|
|
700
|
+
expect([...multipliers].sort((a, b) => a - b)).toEqual(multipliers);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
test("follows --radius, so a pasted theme moves the bars with the buttons", () => {
|
|
704
|
+
expect(resolveBarRadius("md", 10)).toBe(10 * BAR_RADIUS_MULTIPLIER.md);
|
|
705
|
+
expect(resolveBarRadius("md", 20)).toBe(20 * BAR_RADIUS_MULTIPLIER.md);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("rounded={false} is square, a number is that number", () => {
|
|
709
|
+
expect(resolveBarRadius("lg", 10, false)).toBe(0);
|
|
710
|
+
expect(resolveBarRadius("lg", 10, 3)).toBe(3);
|
|
711
|
+
expect(resolveBarRadius("lg", 10, true)).toBe(10 * BAR_RADIUS_MULTIPLIER.lg);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("never goes negative and survives a missing --radius", () => {
|
|
715
|
+
expect(resolveBarRadius("md", Number.NaN)).toBe(0);
|
|
716
|
+
expect(resolveBarRadius("md", 10, -4)).toBe(0);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
describe("candle sentiment tokens", () => {
|
|
721
|
+
test("borrow tokens every theme variant declares", () => {
|
|
722
|
+
for (const variant of ["light", "dark"] as const) {
|
|
723
|
+
const declared = declaredTokens(variant);
|
|
724
|
+
for (const token of Object.values(CANDLE_SENTIMENT_TOKENS)) {
|
|
725
|
+
expect(declared).toContain(token);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("name one token per sentiment", () => {
|
|
731
|
+
expect(Object.keys(CANDLE_SENTIMENT_TOKENS).sort()).toEqual(["negative", "neutral", "positive"]);
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
describe("resolveTooltipRows", () => {
|
|
736
|
+
const series: ChartResolvedSeries[] = [
|
|
737
|
+
{ key: "desktop", label: "Desktop", color: "#111" },
|
|
738
|
+
{ key: "mobile", label: "Mobile", color: "#222" },
|
|
739
|
+
];
|
|
740
|
+
const keys = { open: "o", high: "h", low: "l", close: "c" };
|
|
741
|
+
const colors = { positive: "up", negative: "down", neutral: "flat" };
|
|
742
|
+
|
|
743
|
+
test("one row per series without a candlestick", () => {
|
|
744
|
+
const rows = resolveTooltipRows(series, null, { desktop: 1, mobile: 2 });
|
|
745
|
+
expect(rows).toEqual([
|
|
746
|
+
{ key: "desktop", label: "Desktop", color: "#111", value: 1 },
|
|
747
|
+
{ key: "mobile", label: "Mobile", color: "#222", value: 2 },
|
|
748
|
+
]);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("an absent row leaves the values undefined rather than throwing", () => {
|
|
752
|
+
expect(resolveTooltipRows(series, null, undefined).map((row) => row.value)).toEqual([undefined, undefined]);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("prints open, high, low and close in that order, swatched by the candle's sentiment", () => {
|
|
756
|
+
const candle = { keys, colors };
|
|
757
|
+
const rows = resolveTooltipRows([{ key: "c", label: "Price", color: "#333" }], candle, { o: 1, h: 3, l: 0, c: 2 });
|
|
758
|
+
expect(rows.map((row) => row.label)).toEqual(["Open", "High", "Low", "Close"]);
|
|
759
|
+
expect(rows.map((row) => row.value)).toEqual([1, 3, 0, 2]);
|
|
760
|
+
expect(rows.every((row) => row.color === "up")).toBe(true);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a falling candle swatches down, a flat one neutral", () => {
|
|
764
|
+
const candle = { keys, colors };
|
|
765
|
+
expect(resolveTooltipRows([], candle, { o: 2, c: 1 })[0]?.color).toBe("down");
|
|
766
|
+
expect(resolveTooltipRows([], candle, { o: 2, c: 2 })[0]?.color).toBe("flat");
|
|
767
|
+
expect(resolveTooltipRows([], candle, undefined)[0]?.color).toBe("flat");
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
test("a series that is not a candle field still gets its own row, after the candle", () => {
|
|
771
|
+
const candle = { keys, colors };
|
|
772
|
+
const rows = resolveTooltipRows(
|
|
773
|
+
[
|
|
774
|
+
{ key: "c", label: "Price", color: "#333" },
|
|
775
|
+
{ key: "volume", label: "Volume", color: "#444" },
|
|
776
|
+
],
|
|
777
|
+
candle,
|
|
778
|
+
{ o: 1, h: 3, l: 0, c: 2, volume: 9 }
|
|
779
|
+
);
|
|
780
|
+
expect(rows.map((row) => row.key)).toEqual(["o", "h", "l", "c", "volume"]);
|
|
781
|
+
expect(rows[4]?.color).toBe("#444");
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe("resolveCategoryTickCount", () => {
|
|
786
|
+
test("labels every category on a bar chart", () => {
|
|
787
|
+
// A bar's category name is that bar's label. Downsampling six months to
|
|
788
|
+
// four ticks drops Feb and May, and a bar with no name under it reads
|
|
789
|
+
// as a bug rather than as a tick budget.
|
|
790
|
+
expect(resolveCategoryTickCount("sm", 6, true)).toBe(6);
|
|
791
|
+
expect(resolveCategoryTickCount("sm", 4, true)).toBe(4);
|
|
792
|
+
expect(resolveCategoryTickCount("md", 5, true)).toBe(5);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("caps at twelve, where the labels would collide anyway", () => {
|
|
796
|
+
expect(resolveCategoryTickCount("lg", 30, true)).toBe(12);
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("never asks for fewer ticks than the size would", () => {
|
|
800
|
+
for (const size of CHART_SIZES) {
|
|
801
|
+
expect(resolveCategoryTickCount(size, 1, true)).toBe(chartTickCount(size));
|
|
802
|
+
expect(resolveCategoryTickCount(size, 0, true)).toBe(chartTickCount(size));
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
test("a chart without bands keeps the size's count", () => {
|
|
807
|
+
for (const size of CHART_SIZES) {
|
|
808
|
+
expect(resolveCategoryTickCount(size, 6, false)).toBe(chartTickCount(size));
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
describe("resolveAreaFill", () => {
|
|
814
|
+
test("a lone area fades out from a light top", () => {
|
|
815
|
+
expect(resolveAreaFill({ stacked: false })).toEqual({ gradient: true, opacity: AREA_FILL_OPACITY });
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("a stacked band is flat and darker, so the bands stay distinct", () => {
|
|
819
|
+
// Three bands each fading to transparent over the full plot height
|
|
820
|
+
// blur into one wash; nothing says where one series ends.
|
|
821
|
+
const fill = resolveAreaFill({ stacked: true });
|
|
822
|
+
expect(fill.gradient).toBe(false);
|
|
823
|
+
expect(fill.opacity).toBe(AREA_STACKED_FILL_OPACITY);
|
|
824
|
+
expect(AREA_STACKED_FILL_OPACITY).toBeGreaterThan(AREA_FILL_OPACITY);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("an explicit gradient or opacity wins either way", () => {
|
|
828
|
+
expect(resolveAreaFill({ stacked: true, gradient: true })).toEqual({
|
|
829
|
+
gradient: true,
|
|
830
|
+
opacity: AREA_STACKED_FILL_OPACITY,
|
|
831
|
+
});
|
|
832
|
+
expect(resolveAreaFill({ stacked: true, opacity: 0.9 }).opacity).toBe(0.9);
|
|
833
|
+
expect(resolveAreaFill({ stacked: false, gradient: false, opacity: 0.5 })).toEqual({
|
|
834
|
+
gradient: false,
|
|
835
|
+
opacity: 0.5,
|
|
836
|
+
});
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
describe("resolveDomainDefaults", () => {
|
|
841
|
+
const none = resolveBarLayout([]);
|
|
842
|
+
const single = resolveBarLayout([{ yKey: "a" }]);
|
|
843
|
+
const keys = { open: "o", high: "h", low: "l", close: "c" };
|
|
844
|
+
|
|
845
|
+
test("a line chart pads nothing and leaves includeZero alone", () => {
|
|
846
|
+
expect(resolveDomainDefaults({ bars: none, candlestick: null, scatter: false })).toEqual({
|
|
847
|
+
includeZero: undefined,
|
|
848
|
+
domainPadding: undefined,
|
|
849
|
+
});
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
test("bars pull zero in and pad x by half a step", () => {
|
|
853
|
+
const defaults = resolveDomainDefaults({ bars: single, candlestick: null, scatter: false });
|
|
854
|
+
expect(defaults.includeZero).toBe(true);
|
|
855
|
+
expect(defaults.domainPadding).toEqual({ x: 0.5 });
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
test("a scatter pads x so the outermost dots clear the plot's edges, but does not pull zero in", () => {
|
|
859
|
+
const defaults = resolveDomainDefaults({ bars: none, candlestick: null, scatter: true });
|
|
860
|
+
expect(defaults.includeZero).toBeUndefined();
|
|
861
|
+
expect(defaults.domainPadding).toEqual({ x: 0.5 });
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
test("candles keep the data's own extent, padded a tenth each way", () => {
|
|
865
|
+
// Prices in the 140–160 band on an axis from zero are a thin strip.
|
|
866
|
+
const defaults = resolveDomainDefaults({ bars: none, candlestick: keys, scatter: false });
|
|
867
|
+
expect(defaults.includeZero).toBeUndefined();
|
|
868
|
+
expect(defaults.domainPadding).toEqual({ x: 0.5, y: 0.1 });
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("an explicit y domain turns the candle's y padding off", () => {
|
|
872
|
+
const defaults = resolveDomainDefaults({
|
|
873
|
+
bars: none,
|
|
874
|
+
candlestick: keys,
|
|
875
|
+
scatter: false,
|
|
876
|
+
domain: { y: [100, 200] },
|
|
877
|
+
});
|
|
878
|
+
expect(defaults.domainPadding).toEqual({ x: 0.5 });
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
test("the caller's includeZero wins over every default", () => {
|
|
882
|
+
expect(
|
|
883
|
+
resolveDomainDefaults({ bars: single, candlestick: null, scatter: false, includeZero: false }).includeZero
|
|
884
|
+
).toBe(false);
|
|
885
|
+
expect(
|
|
886
|
+
resolveDomainDefaults({ bars: none, candlestick: keys, scatter: false, includeZero: true }).includeZero
|
|
887
|
+
).toBe(true);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("bars beside candles still pull zero in — the bars need it", () => {
|
|
891
|
+
expect(resolveDomainDefaults({ bars: single, candlestick: keys, scatter: false }).includeZero).toBe(true);
|
|
892
|
+
});
|
|
893
|
+
});
|