@multiplatform.one/theme 6.4.2 → 6.6.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/package.json +5 -5
- package/src/dates.spec.ts +158 -1
- package/src/dates.ts +37 -0
- package/src/devWarn.ts +36 -0
- package/src/numbers.spec.ts +112 -0
- package/src/numbers.ts +26 -8
- package/src/theme/cookies.spec.ts +84 -0
- package/src/theme/cookies.ts +61 -13
- package/src/theme/index.ts +1 -0
- package/src/theme/radiusClass.ts +51 -0
- package/src/theme/readableColor.spec.ts +115 -0
- package/src/theme/readableColor.ts +51 -30
- package/src/theme/resolveKnobs.spec.ts +46 -2
- package/types/dates.d.ts +82 -2
- package/types/dates.d.ts.map +1 -1
- package/types/devWarn.d.ts +35 -0
- package/types/devWarn.d.ts.map +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/numbers.d.ts +74 -0
- package/types/numbers.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/cookies.d.ts +18 -4
- package/types/theme/cookies.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +2 -2
- package/types/theme/index.d.ts +1 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +23 -0
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/presets.d.ts +15 -0
- package/types/theme/presets.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +44 -0
- package/types/theme/radiusClass.d.ts.map +1 -0
- package/types/theme/readableColor.d.ts +18 -6
- package/types/theme/readableColor.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +10 -1
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +12 -1
- package/types/theme/resolveKnobs.d.ts.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/theme",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "Tamagui theme system for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"multiplatform",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"@tamagui/toast": "2.0.0-rc.41",
|
|
54
54
|
"@tamagui/web": "2.0.0-rc.41",
|
|
55
55
|
"react-cookie": "^8.1.2",
|
|
56
|
-
"@multiplatform.one/platform": "6.
|
|
57
|
-
"@multiplatform.one/store": "6.
|
|
56
|
+
"@multiplatform.one/platform": "6.6.0",
|
|
57
|
+
"@multiplatform.one/store": "6.6.0"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@tamagui/animations-css": "2.0.0-rc.41",
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
"tamagui": "2.0.0-rc.41",
|
|
70
70
|
"typescript": "~5.9.3",
|
|
71
71
|
"vitest": "^4.1.5",
|
|
72
|
-
"@multiplatform.one/config": "6.
|
|
73
|
-
"@multiplatform.one/test-utils": "6.
|
|
72
|
+
"@multiplatform.one/config": "6.6.0",
|
|
73
|
+
"@multiplatform.one/test-utils": "6.6.0"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
76
|
"@tamagui/animations-css": "^2.0.0-rc",
|
package/src/dates.spec.ts
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
formatAbsoluteDate,
|
|
4
|
+
formatAbsoluteDateTime,
|
|
5
|
+
formatDayLong,
|
|
6
|
+
formatDayOfMonth,
|
|
7
|
+
formatMonthYear,
|
|
8
|
+
formatRelativeTimestamp,
|
|
9
|
+
formatTimeOfDay,
|
|
10
|
+
getMonthNames,
|
|
11
|
+
getWeekdayNames,
|
|
12
|
+
parseCalendarDate,
|
|
13
|
+
type DateInput,
|
|
14
|
+
} from "./dates";
|
|
3
15
|
|
|
4
16
|
// Pin locale so assertions don't depend on the host machine.
|
|
5
17
|
const enUS = { locale: "en-US" } as const;
|
|
@@ -73,3 +85,148 @@ describe("formatRelativeTimestamp (feed/social register)", () => {
|
|
|
73
85
|
expect(formatRelativeTimestamp("garbage", base)).toBe("garbage");
|
|
74
86
|
});
|
|
75
87
|
});
|
|
88
|
+
|
|
89
|
+
describe("formatTimeOfDay (time-of-day register)", () => {
|
|
90
|
+
it("renders hour and minutes, never seconds", () => {
|
|
91
|
+
expect(formatTimeOfDay(new Date(2026, 7, 12, 14, 30, 45), enUS)).toBe("2:30 PM");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("drops minutes for hour ticks", () => {
|
|
95
|
+
expect(formatTimeOfDay(new Date(2026, 7, 12, 14, 0), { ...enUS, minutes: false })).toBe("2 PM");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("accepts a clock-time string — the wire shape Frappe Time fields send", () => {
|
|
99
|
+
expect(formatTimeOfDay("14:30", enUS)).toBe("2:30 PM");
|
|
100
|
+
expect(formatTimeOfDay("14:30:00", enUS)).toBe("2:30 PM");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("passes unparseable strings through and renders empty input empty", () => {
|
|
104
|
+
expect(formatTimeOfDay("later", enUS)).toBe("later");
|
|
105
|
+
expect(formatTimeOfDay(null, enUS)).toBe("");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("calendar chrome registers", () => {
|
|
110
|
+
it("speaks a long day name for headers and accessible names", () => {
|
|
111
|
+
expect(formatDayLong(new Date(2026, 2, 10), enUS)).toBe("Tuesday, March 10");
|
|
112
|
+
expect(formatDayLong(new Date(2026, 2, 10), { ...enUS, weekday: false })).toBe("March 10");
|
|
113
|
+
expect(formatDayLong(new Date(2026, 2, 10), { ...enUS, year: true })).toBe(
|
|
114
|
+
"Tuesday, March 10, 2026",
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("names months and weekdays for calendar grids", () => {
|
|
119
|
+
expect(getMonthNames(enUS)[0]).toBe("January");
|
|
120
|
+
expect(getMonthNames({ ...enUS, style: "short" })[2]).toBe("Mar");
|
|
121
|
+
expect(getWeekdayNames(enUS)).toEqual(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]);
|
|
122
|
+
expect(getWeekdayNames({ ...enUS, firstDay: 1 })[0]).toBe("Mon");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("axis-tick registers", () => {
|
|
127
|
+
it("drops the year entirely so ticks keep a stable width", () => {
|
|
128
|
+
expect(formatAbsoluteDate(new Date(2025, 2, 10), { ...enUS, now, year: "never" })).toBe(
|
|
129
|
+
"Mar 10",
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("renders month-year and day-of-month ticks", () => {
|
|
134
|
+
expect(formatMonthYear(new Date(2026, 2, 10), enUS)).toBe("Mar 2026");
|
|
135
|
+
expect(formatMonthYear(new Date(2026, 2, 10), { ...enUS, month: "long" })).toBe("March 2026");
|
|
136
|
+
expect(formatDayOfMonth(new Date(2026, 2, 10), enUS)).toBe("10");
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Spans UTC, two negative offsets and the far positive extreme (UTC+14), so a
|
|
141
|
+
// parse that leans on the host zone fails somewhere in this list.
|
|
142
|
+
const ZONES = ["UTC", "America/Los_Angeles", "America/New_York", "Pacific/Kiritimati"] as const;
|
|
143
|
+
|
|
144
|
+
function inZone<T>(timeZone: string, read: () => T): T {
|
|
145
|
+
const previous = process.env.TZ;
|
|
146
|
+
process.env.TZ = timeZone;
|
|
147
|
+
try {
|
|
148
|
+
return read();
|
|
149
|
+
} finally {
|
|
150
|
+
if (previous === undefined) delete process.env.TZ;
|
|
151
|
+
else process.env.TZ = previous;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Local wall-clock reading of a parse, taken INSIDE the zone — a Date is an
|
|
157
|
+
* instant, so its day and hour only mean something against an ambient zone.
|
|
158
|
+
*/
|
|
159
|
+
function readLocal(timeZone: string, value: DateInput) {
|
|
160
|
+
return inZone(timeZone, () => {
|
|
161
|
+
const date = parseCalendarDate(value);
|
|
162
|
+
if (!date) return null;
|
|
163
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
164
|
+
return {
|
|
165
|
+
day: `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`,
|
|
166
|
+
clock: `${pad(date.getHours())}:${pad(date.getMinutes())}`,
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("parseCalendarDate (day-grid parse)", () => {
|
|
172
|
+
it("reads a date-only string as the same calendar day in every timezone", () => {
|
|
173
|
+
for (const timeZone of ZONES) {
|
|
174
|
+
expect({ timeZone, ...readLocal(timeZone, "2026-03-10") }).toEqual({
|
|
175
|
+
timeZone,
|
|
176
|
+
day: "2026-03-10",
|
|
177
|
+
// Local midnight, which is what day-grid math tests for: Gantt's
|
|
178
|
+
// normalizeSpanEdges only grants a date-only end its inclusive last
|
|
179
|
+
// day when the edge sits exactly on midnight.
|
|
180
|
+
clock: "00:00",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("guards the ECMA-262 default this exists to avoid", () => {
|
|
186
|
+
// `new Date("YYYY-MM-DD")` is UTC midnight, so the naive parse names the
|
|
187
|
+
// previous day west of UTC. If this ever stops shifting, the test above
|
|
188
|
+
// has stopped proving anything.
|
|
189
|
+
const naive = inZone("America/Los_Angeles", () => new Date("2026-03-10").getDate());
|
|
190
|
+
expect(naive).toBe(9);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("keeps the timezone-aware parse for values that carry a time", () => {
|
|
194
|
+
for (const timeZone of ZONES) {
|
|
195
|
+
// No offset in the string — a local wall-clock reading, unchanged.
|
|
196
|
+
expect(readLocal(timeZone, "2026-03-10 14:30:00")).toEqual({
|
|
197
|
+
day: "2026-03-10",
|
|
198
|
+
clock: "14:30",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// An explicit offset names one instant, which lands on different local
|
|
202
|
+
// days by design — genuine datetime semantics are untouched.
|
|
203
|
+
expect(readLocal("UTC", "2026-03-10T00:00:00Z")).toEqual({ day: "2026-03-10", clock: "00:00" });
|
|
204
|
+
expect(readLocal("America/Los_Angeles", "2026-03-10T00:00:00Z")).toEqual({
|
|
205
|
+
day: "2026-03-09",
|
|
206
|
+
clock: "17:00", // PDT — March 10 is past the 2026 DST switch
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("passes Date instances and epoch millis through untouched", () => {
|
|
211
|
+
const instance = new Date(2026, 2, 10, 9, 15);
|
|
212
|
+
expect(parseCalendarDate(instance)).toBe(instance);
|
|
213
|
+
expect(parseCalendarDate(Date.UTC(2026, 2, 10))?.toISOString()).toBe(
|
|
214
|
+
"2026-03-10T00:00:00.000Z",
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("returns null for empty, unparseable and out-of-range input", () => {
|
|
219
|
+
expect(parseCalendarDate(null)).toBeNull();
|
|
220
|
+
expect(parseCalendarDate(undefined)).toBeNull();
|
|
221
|
+
expect(parseCalendarDate("")).toBeNull();
|
|
222
|
+
expect(parseCalendarDate("not-a-date")).toBeNull();
|
|
223
|
+
expect(parseCalendarDate(new Date("nonsense"))).toBeNull();
|
|
224
|
+
// Rolled over rather than rejected would silently render April 14.
|
|
225
|
+
expect(parseCalendarDate("2026-13-45")).toBeNull();
|
|
226
|
+
expect(parseCalendarDate("2026-02-30")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps a two-digit-looking year literal instead of mapping it to 19xx", () => {
|
|
230
|
+
expect(inZone("UTC", () => parseCalendarDate("0050-03-10")?.getFullYear())).toBe(50);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/dates.ts
CHANGED
|
@@ -65,6 +65,43 @@ function coerceDateInput(value: DateInput): CoercedDate | null {
|
|
|
65
65
|
return Number.isNaN(date.getTime()) ? null : { date, dateOnly: false };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Parse a date value onto the LOCAL calendar day it names — the canonical
|
|
70
|
+
* parse for anything that will be positioned on a day grid (calendar cells,
|
|
71
|
+
* Gantt bars, date axes) rather than merely formatted.
|
|
72
|
+
*
|
|
73
|
+
* A date-only string ("YYYY-MM-DD") is a calendar date carrying no timezone,
|
|
74
|
+
* which is how Frappe sends `Date` fields. ECMA-262 parses that form as UTC
|
|
75
|
+
* midnight, so every zone west of UTC reads it as the *previous* local day,
|
|
76
|
+
* and no zone but UTC lands it on the local midnight that day-grid math tests
|
|
77
|
+
* for. Those are built from local parts instead.
|
|
78
|
+
*
|
|
79
|
+
* Values that carry a time ("YYYY-MM-DD HH:MM:SS", ISO with an offset, epoch
|
|
80
|
+
* millis) are genuine instants and keep the timezone-aware parse untouched.
|
|
81
|
+
*
|
|
82
|
+
* Returns null for empty and unparseable input, and for out-of-range parts
|
|
83
|
+
* ("2026-13-45") rather than silently rolling them into the next month.
|
|
84
|
+
*/
|
|
85
|
+
export function parseCalendarDate(value: DateInput): Date | null {
|
|
86
|
+
if (value == null || value === "") return null;
|
|
87
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
|
88
|
+
if (typeof value === "number") {
|
|
89
|
+
const fromEpoch = new Date(value);
|
|
90
|
+
return Number.isNaN(fromEpoch.getTime()) ? null : fromEpoch;
|
|
91
|
+
}
|
|
92
|
+
const trimmed = value.trim();
|
|
93
|
+
if (ISO_DATE_ONLY.test(trimmed)) {
|
|
94
|
+
const [year, month, day] = trimmed.split("-").map(Number);
|
|
95
|
+
const date = new Date(year, month - 1, day);
|
|
96
|
+
// `new Date(50, …)` means 1950 — pin the year the string actually named.
|
|
97
|
+
if (year < 100) date.setFullYear(year);
|
|
98
|
+
if (date.getMonth() !== month - 1 || date.getDate() !== day) return null;
|
|
99
|
+
return date;
|
|
100
|
+
}
|
|
101
|
+
const parsed = new Date(trimmed.replace(" ", "T"));
|
|
102
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
103
|
+
}
|
|
104
|
+
|
|
68
105
|
/**
|
|
69
106
|
* Compact absolute date — the house register for every absolute date.
|
|
70
107
|
* "Jul 1" / "Jul 1, 2025" (year auto-elided when current), or with
|
package/src/devWarn.ts
CHANGED
|
@@ -32,6 +32,42 @@ export type DevWarnDetail = {
|
|
|
32
32
|
[key: string]: unknown;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* LC-57 GUARDRAIL-CLEAN-STORIES allowlist entry, declared as a Storybook story
|
|
37
|
+
* parameter (`parameters.guardrailSpecimen`).
|
|
38
|
+
*
|
|
39
|
+
* Catalog stories emit zero `[theme]` guardrail warnings at rest. A story whose
|
|
40
|
+
* whole purpose is to demonstrate the warned state (a disabled-dial specimen, a
|
|
41
|
+
* pager boundary case) is allowlisted by declaring itself:
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* export const Disabled: Story = {
|
|
45
|
+
* parameters: {
|
|
46
|
+
* guardrailSpecimen: {
|
|
47
|
+
* warns: ["bare-disabled"],
|
|
48
|
+
* reason: "Demonstrates the disabled dial; Stepper has no disabledReason plumbing.",
|
|
49
|
+
* } satisfies GuardrailSpecimenParameter,
|
|
50
|
+
* },
|
|
51
|
+
* };
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Semantics, pinned:
|
|
55
|
+
* - `warns` is the CLOSED set of codes the story may emit at rest. An emitted
|
|
56
|
+
* code outside the set still FAILs the story.
|
|
57
|
+
* - A declared code that happens not to fire passes — component improvements
|
|
58
|
+
* must never break an allowlist entry.
|
|
59
|
+
* - The declaration NEVER suppresses the console warning — the warn still
|
|
60
|
+
* fires; only the warn-harvester tolerates it (explicit, never tacit).
|
|
61
|
+
* - Harvesters (`p-results/story-hygiene/verify.mjs`) read this parameter off
|
|
62
|
+
* the rendered story and fail CLOSED when parameters cannot be read at all.
|
|
63
|
+
*/
|
|
64
|
+
export type GuardrailSpecimenParameter = {
|
|
65
|
+
/** Warn codes this story may emit at rest — a closed set, not a wildcard. */
|
|
66
|
+
warns: DevWarnCode[];
|
|
67
|
+
/** Why the warned state is the story's subject (recorded per LC-57). */
|
|
68
|
+
reason: string;
|
|
69
|
+
};
|
|
70
|
+
|
|
35
71
|
const seen = new Set<string>();
|
|
36
72
|
|
|
37
73
|
function isDev(): boolean {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { formatCurrency, formatNumber, formatPercent, resolveCurrencyCode } from "./numbers";
|
|
3
|
+
|
|
4
|
+
// Pin locale so assertions don't depend on the host machine.
|
|
5
|
+
const enUS = { locale: "en-US" } as const;
|
|
6
|
+
|
|
7
|
+
describe("formatNumber (LC-12 cardinal register)", () => {
|
|
8
|
+
it("groups thousands — the aggregate that used to render raw", () => {
|
|
9
|
+
expect(formatNumber(15400, enUS)).toBe("15,400");
|
|
10
|
+
expect(formatNumber(1234567, enUS)).toBe("1,234,567");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps up to two fraction digits without inventing any", () => {
|
|
14
|
+
expect(formatNumber(5133.33, enUS)).toBe("5,133.33");
|
|
15
|
+
expect(formatNumber(5133.3, enUS)).toBe("5,133.3");
|
|
16
|
+
expect(formatNumber(5133, enUS)).toBe("5,133");
|
|
17
|
+
expect(formatNumber(5133.336, enUS)).toBe("5,133.34");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("pins fraction digits with precision (fixed-precision columns)", () => {
|
|
21
|
+
expect(formatNumber(15400, { ...enUS, precision: 2 })).toBe("15,400.00");
|
|
22
|
+
expect(formatNumber(15400.6, { ...enUS, precision: 0 })).toBe("15,401");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("honours a wider maximum for report floats", () => {
|
|
26
|
+
expect(formatNumber(3.14159, { ...enUS, maximumFractionDigits: 4 })).toBe("3.1416");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("can drop grouping when a caller owns the layout", () => {
|
|
30
|
+
expect(formatNumber(15400, { ...enUS, grouping: false })).toBe("15400");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("coerces numeric strings — wire values still reach the register", () => {
|
|
34
|
+
expect(formatNumber("15400", enUS)).toBe("15,400");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("renders empty input as empty and passes unparseable text through", () => {
|
|
38
|
+
expect(formatNumber(null, enUS)).toBe("");
|
|
39
|
+
expect(formatNumber(undefined, enUS)).toBe("");
|
|
40
|
+
expect(formatNumber("", enUS)).toBe("");
|
|
41
|
+
expect(formatNumber("n/a", enUS)).toBe("n/a");
|
|
42
|
+
expect(formatNumber(Number.NaN, enUS)).toBe("");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("formatCurrency (LC-12 money register)", () => {
|
|
47
|
+
it("defaults to two fraction digits so a money column stays aligned", () => {
|
|
48
|
+
expect(formatCurrency(15400, enUS)).toBe("15,400.00");
|
|
49
|
+
expect(formatCurrency(15400, { ...enUS, symbol: "$" })).toBe("$15,400.00");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("elides a whole-value .00 in the summary-tile variant", () => {
|
|
53
|
+
expect(formatCurrency(48250, { ...enUS, symbol: "$", fractionDigits: "auto" })).toBe("$48,250");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("still shows cents in the summary variant when the value has them", () => {
|
|
57
|
+
expect(formatCurrency(48250.5, { ...enUS, symbol: "$", fractionDigits: "auto" })).toBe(
|
|
58
|
+
"$48,250.50",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("renders an ISO code through the currency's own symbol placement", () => {
|
|
63
|
+
expect(formatCurrency(15400, { ...enUS, currency: "USD" })).toBe("$15,400.00");
|
|
64
|
+
expect(formatCurrency(15400, { ...enUS, currency: "EUR" })).toBe("€15,400.00");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("resolves a known symbol to its code, prefixes an unknown one", () => {
|
|
68
|
+
expect(formatCurrency(1500, { ...enUS, symbol: "₹" })).toBe("₹1,500.00");
|
|
69
|
+
expect(formatCurrency(1500, { ...enUS, symbol: "¤" })).toBe("¤1,500.00");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("keeps the grouped register when a bad code makes Intl throw", () => {
|
|
73
|
+
// A Frappe field can hand over a malformed code; the money register must
|
|
74
|
+
// still group ("EUROS1,500.00", never a bare "1500").
|
|
75
|
+
expect(formatCurrency(1500, { ...enUS, currency: "EUROS" })).toBe("EUROS1,500.00");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("honours an explicit precision from a Frappe field", () => {
|
|
79
|
+
expect(formatCurrency(1500.456, { ...enUS, symbol: "$", precision: 3 })).toBe("$1,500.456");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders empty input as empty", () => {
|
|
83
|
+
expect(formatCurrency(null, enUS)).toBe("");
|
|
84
|
+
expect(formatCurrency("", { ...enUS, symbol: "$" })).toBe("");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("formatPercent (LC-12 rate register)", () => {
|
|
89
|
+
it("takes percent units, not a ratio", () => {
|
|
90
|
+
expect(formatPercent(94.2, enUS)).toBe("94.2%");
|
|
91
|
+
expect(formatPercent(3, enUS)).toBe("3%");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("pins report precision", () => {
|
|
95
|
+
expect(formatPercent(94.25, { ...enUS, precision: 1 })).toBe("94.3%");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders empty input as empty", () => {
|
|
99
|
+
expect(formatPercent(null, enUS)).toBe("");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("resolveCurrencyCode", () => {
|
|
104
|
+
it("maps the common symbols and passes codes through", () => {
|
|
105
|
+
expect(resolveCurrencyCode("$")).toBe("USD");
|
|
106
|
+
expect(resolveCurrencyCode("₹")).toBe("INR");
|
|
107
|
+
expect(resolveCurrencyCode("eur")).toBe("EUR");
|
|
108
|
+
expect(resolveCurrencyCode("R$")).toBe("BRL");
|
|
109
|
+
expect(resolveCurrencyCode("¤")).toBeUndefined();
|
|
110
|
+
expect(resolveCurrencyCode(undefined)).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/numbers.ts
CHANGED
|
@@ -62,6 +62,8 @@ const CURRENCY_SYMBOL_TO_CODE: Record<string, string> = {
|
|
|
62
62
|
"¥": "JPY",
|
|
63
63
|
"₹": "INR",
|
|
64
64
|
"₩": "KRW",
|
|
65
|
+
"₽": "RUB",
|
|
66
|
+
R$: "BRL",
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
/**
|
|
@@ -85,10 +87,9 @@ function coerceNumberInput(value: NumberInput): CoercedNumber | null {
|
|
|
85
87
|
return Number.isFinite(num) ? { num } : null;
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
function resolveDigits(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
> {
|
|
90
|
+
function resolveDigits(
|
|
91
|
+
options: NumberFormatOptions,
|
|
92
|
+
): Pick<Intl.NumberFormatOptions, "minimumFractionDigits" | "maximumFractionDigits"> {
|
|
92
93
|
const { precision, minimumFractionDigits, maximumFractionDigits } = options;
|
|
93
94
|
if (precision != null) {
|
|
94
95
|
return { minimumFractionDigits: precision, maximumFractionDigits: precision };
|
|
@@ -98,14 +99,22 @@ function resolveDigits(options: NumberFormatOptions): Pick<
|
|
|
98
99
|
return { minimumFractionDigits: min, maximumFractionDigits: Math.max(min, max) };
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
function
|
|
102
|
+
function tryFormatWith(
|
|
103
|
+
num: number,
|
|
104
|
+
options: Intl.NumberFormatOptions,
|
|
105
|
+
locale?: string,
|
|
106
|
+
): string | null {
|
|
102
107
|
try {
|
|
103
108
|
return new Intl.NumberFormat(locale, options).format(num);
|
|
104
109
|
} catch {
|
|
105
|
-
return
|
|
110
|
+
return null;
|
|
106
111
|
}
|
|
107
112
|
}
|
|
108
113
|
|
|
114
|
+
function formatWith(num: number, options: Intl.NumberFormatOptions, locale?: string): string {
|
|
115
|
+
return tryFormatWith(num, options, locale) ?? String(num);
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
/**
|
|
110
119
|
* Cardinal / aggregate register — grouped digits, at most two fraction
|
|
111
120
|
* digits ("15,400", "5,133.33"). Empty input renders "" and an unparseable
|
|
@@ -143,10 +152,19 @@ export function formatCurrency(value: NumberInput, options: CurrencyFormatOption
|
|
|
143
152
|
const code = currency ?? resolveCurrencyCode(symbol);
|
|
144
153
|
const useGrouping = grouping ?? true;
|
|
145
154
|
if (code) {
|
|
146
|
-
|
|
155
|
+
// A malformed code from a Frappe field must not cost the whole money
|
|
156
|
+
// register: fall back to the grouped register with the label prefixed
|
|
157
|
+
// rather than letting Intl's throw leak a bare `String(num)`.
|
|
158
|
+
const styled = tryFormatWith(
|
|
159
|
+
coerced.num,
|
|
160
|
+
{ ...digits, useGrouping, style: "currency", currency: code },
|
|
161
|
+
locale,
|
|
162
|
+
);
|
|
163
|
+
if (styled != null) return styled;
|
|
147
164
|
}
|
|
148
165
|
const amount = formatWith(coerced.num, { ...digits, useGrouping }, locale);
|
|
149
|
-
|
|
166
|
+
const label = symbol ?? currency;
|
|
167
|
+
return label ? `${label}${amount}` : amount;
|
|
150
168
|
}
|
|
151
169
|
|
|
152
170
|
/**
|
|
@@ -104,6 +104,85 @@ describe("readOverridesCookie", () => {
|
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
+
// Regression: react-cookie's jar (universal-cookie) auto-JSON-parses every
|
|
108
|
+
// cookie value it can, so the KnobBridge/useTheme read path receives
|
|
109
|
+
// already-parsed OBJECTS — the old reader joined them into "[object Object]",
|
|
110
|
+
// JSON.parse threw, and overrides silently resolved to {} app-wide.
|
|
111
|
+
describe("readOverridesCookie with universal-cookie auto-parsed values", () => {
|
|
112
|
+
it("accepts an already-parsed object cookie value (universal-cookie shape)", () => {
|
|
113
|
+
const overrides: Partial<Knobs> = { borderRadius: "none", space: "small" };
|
|
114
|
+
expect(readOverridesCookie({ "mp.ov.0": overrides })).toEqual(overrides);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("accepts a raw JSON string cookie value (document.cookie / SSR shape)", () => {
|
|
118
|
+
const overrides: Partial<Knobs> = { borderRadius: "none", space: "small" };
|
|
119
|
+
expect(readOverridesCookie({ "mp.ov.0": JSON.stringify(overrides) })).toEqual(overrides);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("preserves nested interaction-state overrides from a parsed object", () => {
|
|
123
|
+
const overrides: Partial<Knobs> = { hover: { elevation: "large" } };
|
|
124
|
+
expect(readOverridesCookie({ "mp.ov.0": overrides })).toEqual(overrides);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("round-trips writeOverridesCookie output through an auto-parsing jar", () => {
|
|
128
|
+
const overrides: Partial<Knobs> = { density: "compact", fillStyle: "outlined" };
|
|
129
|
+
const { toSet } = writeOverridesCookie(overrides);
|
|
130
|
+
// Simulate universal-cookie readCookie: JSON.parse succeeds on the
|
|
131
|
+
// single-chunk value, so the jar hands back an object.
|
|
132
|
+
const jar = Object.fromEntries(toSet.map(({ key, value }) => [key, JSON.parse(value)]));
|
|
133
|
+
expect(readOverridesCookie(jar)).toEqual(overrides);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("round-trips oversized multi-chunk payloads through an auto-parsing jar", () => {
|
|
137
|
+
const bigValue = "x".repeat(8000);
|
|
138
|
+
const overrides = { animation: bigValue } as unknown as Partial<Knobs>;
|
|
139
|
+
const { toSet } = writeOverridesCookie(overrides);
|
|
140
|
+
expect(toSet.length).toBeGreaterThan(1);
|
|
141
|
+
// universal-cookie leaves each chunk a string: a JSON fragment throws in
|
|
142
|
+
// its JSON.parse attempt, so readCookie falls back to the raw value.
|
|
143
|
+
const jar = Object.fromEntries(
|
|
144
|
+
toSet.map(({ key, value }) => {
|
|
145
|
+
try {
|
|
146
|
+
return [key, JSON.parse(value)];
|
|
147
|
+
} catch {
|
|
148
|
+
return [key, value];
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
expect(readOverridesCookie(jar)).toEqual(overrides);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("returns safe empty for garbage values", () => {
|
|
156
|
+
expect(readOverridesCookie({ "mp.ov.0": 42 })).toEqual({});
|
|
157
|
+
expect(readOverridesCookie({ "mp.ov.0": true })).toEqual({});
|
|
158
|
+
expect(readOverridesCookie({ "mp.ov.0": ["not", "overrides"] })).toEqual({});
|
|
159
|
+
expect(readOverridesCookie({ "mp.ov.0": "not{valid json" })).toEqual({});
|
|
160
|
+
expect(readOverridesCookie({ "mp.ov.0": "[object Object]" })).toEqual({});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns safe empty for mixed chunk types (object followed by string)", () => {
|
|
164
|
+
expect(
|
|
165
|
+
readOverridesCookie({
|
|
166
|
+
"mp.ov.0": { borderRadius: "none" },
|
|
167
|
+
"mp.ov.1": '{"space":"small"}',
|
|
168
|
+
}),
|
|
169
|
+
).toEqual({});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("drops non-knob-shaped entries inside a parsed object", () => {
|
|
173
|
+
expect(
|
|
174
|
+
readOverridesCookie({
|
|
175
|
+
"mp.ov.0": {
|
|
176
|
+
borderRadius: "none",
|
|
177
|
+
count: 3,
|
|
178
|
+
list: ["a"],
|
|
179
|
+
hover: { elevation: "large", depth: { nested: true } },
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
).toEqual({ borderRadius: "none", hover: { elevation: "large" } });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
107
186
|
describe("readPresetCookie", () => {
|
|
108
187
|
it("returns undefined when cookie is missing", () => {
|
|
109
188
|
expect(readPresetCookie({})).toBeUndefined();
|
|
@@ -116,6 +195,11 @@ describe("readPresetCookie", () => {
|
|
|
116
195
|
it("returns the preset name", () => {
|
|
117
196
|
expect(readPresetCookie({ [cookiePreset]: "dark-mode" })).toBe("dark-mode");
|
|
118
197
|
});
|
|
198
|
+
|
|
199
|
+
it("returns undefined for non-string jar values (universal-cookie auto-parse)", () => {
|
|
200
|
+
expect(readPresetCookie({ [cookiePreset]: 123 })).toBeUndefined();
|
|
201
|
+
expect(readPresetCookie({ [cookiePreset]: { nested: true } })).toBeUndefined();
|
|
202
|
+
});
|
|
119
203
|
});
|
|
120
204
|
|
|
121
205
|
describe("writePresetCookie", () => {
|
package/src/theme/cookies.ts
CHANGED
|
@@ -69,34 +69,82 @@ function chunkKey(index: number): string {
|
|
|
69
69
|
// Cookie read helpers (work with raw cookie objects from react-cookie)
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Shape-validate a parsed overrides payload before it reaches knob
|
|
74
|
+
* resolution: keep knob-shaped entries — string values plus one-level
|
|
75
|
+
* StateKnobs-style objects of string values (`hover`, `press`, …) — and drop
|
|
76
|
+
* everything else, so a corrupt or foreign cookie can never spread garbage
|
|
77
|
+
* (arrays, numbers, deep objects) into `{ ...preset.knobs, ...overrides }`.
|
|
78
|
+
*/
|
|
79
|
+
function sanitizeOverrides(parsed: unknown): Partial<Knobs> {
|
|
80
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
const overrides: Record<string, unknown> = {};
|
|
84
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
85
|
+
if (typeof value === "string") {
|
|
86
|
+
overrides[key] = value;
|
|
87
|
+
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
88
|
+
const nested: Record<string, string> = {};
|
|
89
|
+
for (const [stateKey, stateValue] of Object.entries(value)) {
|
|
90
|
+
if (typeof stateValue === "string") nested[stateKey] = stateValue;
|
|
91
|
+
}
|
|
92
|
+
overrides[key] = nested;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return overrides as Partial<Knobs>;
|
|
96
|
+
}
|
|
97
|
+
|
|
72
98
|
/**
|
|
73
99
|
* Read the overrides cookie value, handling chunked cookies.
|
|
74
|
-
*
|
|
100
|
+
*
|
|
101
|
+
* Accepts BOTH value shapes a cookie jar can deliver:
|
|
102
|
+
* - already-parsed OBJECTS — react-cookie's `useCookies()` jar
|
|
103
|
+
* (universal-cookie) auto-JSON-parses every cookie value it can (its
|
|
104
|
+
* `readCookie` tries `JSON.parse` unless `doNotParse` is set), so a
|
|
105
|
+
* single-chunk overrides cookie arrives here as an object, not a string;
|
|
106
|
+
* - raw JSON STRINGS — direct `document.cookie` / SSR cookie-header reads
|
|
107
|
+
* (`loadPresetOverrides`), and multi-chunk payloads on every path (a
|
|
108
|
+
* fragment of a split JSON document does not parse on its own, so even
|
|
109
|
+
* universal-cookie leaves those chunks as strings).
|
|
110
|
+
*
|
|
111
|
+
* Anything else (numbers, arrays, mixed chunk types, malformed JSON) reads
|
|
112
|
+
* as no overrides, and every parsed payload is shape-validated before use.
|
|
75
113
|
*/
|
|
76
|
-
export function readOverridesCookie(cookies: Record<string,
|
|
114
|
+
export function readOverridesCookie(cookies: Record<string, unknown>): Partial<Knobs> {
|
|
77
115
|
// Reconstruct from chunked cookies: mp.ov.0, mp.ov.1, …
|
|
78
|
-
const chunks:
|
|
116
|
+
const chunks: unknown[] = [];
|
|
79
117
|
for (let i = 0; i < maxChunks; i++) {
|
|
80
118
|
const chunk = cookies[chunkKey(i)];
|
|
81
119
|
if (!chunk) break;
|
|
82
120
|
chunks.push(chunk);
|
|
83
121
|
}
|
|
84
|
-
if (chunks.length
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
122
|
+
if (chunks.length === 0) return {};
|
|
123
|
+
|
|
124
|
+
// universal-cookie path: the jar already parsed the JSON for us.
|
|
125
|
+
if (chunks.length === 1 && typeof chunks[0] === "object") {
|
|
126
|
+
return sanitizeOverrides(chunks[0]);
|
|
90
127
|
}
|
|
91
128
|
|
|
92
|
-
|
|
129
|
+
// Raw string path: join chunks and parse the canonical JSON serialization.
|
|
130
|
+
if (!chunks.every((chunk): chunk is string => typeof chunk === "string")) {
|
|
131
|
+
return {};
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return sanitizeOverrides(JSON.parse(joinSplitCookieValues(chunks)));
|
|
135
|
+
} catch {
|
|
136
|
+
return {};
|
|
137
|
+
}
|
|
93
138
|
}
|
|
94
139
|
|
|
95
140
|
/**
|
|
96
|
-
* Read the preset name from the cookie.
|
|
141
|
+
* Read the preset name from the cookie. universal-cookie auto-JSON-parses
|
|
142
|
+
* jar values, so a numeric-looking name would arrive as a number — preset
|
|
143
|
+
* names are strings by contract, anything else reads as no preset.
|
|
97
144
|
*/
|
|
98
|
-
export function readPresetCookie(cookies: Record<string,
|
|
99
|
-
|
|
145
|
+
export function readPresetCookie(cookies: Record<string, unknown>): string | undefined {
|
|
146
|
+
const value = cookies[cookiePreset];
|
|
147
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
100
148
|
}
|
|
101
149
|
|
|
102
150
|
// ---------------------------------------------------------------------------
|
package/src/theme/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export * from "./Preset";
|
|
|
18
18
|
export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
|
|
19
19
|
export * from "./PresetContext";
|
|
20
20
|
export * from "./presets";
|
|
21
|
+
export * from "./radiusClass";
|
|
21
22
|
export * from "./readableColor";
|
|
22
23
|
export * from "./recipes";
|
|
23
24
|
export * from "./resolveKnobs";
|