@assistant-ui/react-generative-ui 0.0.20 → 0.0.22
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/LICENSE +1 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +30 -13
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/RadioGroupScope.d.ts +6 -0
- package/dist/RadioGroupScope.d.ts.map +1 -0
- package/dist/RadioGroupScope.js +25 -0
- package/dist/RadioGroupScope.js.map +1 -0
- package/dist/a2ui/BindingContext.d.ts +11 -0
- package/dist/a2ui/BindingContext.d.ts.map +1 -0
- package/dist/a2ui/BindingContext.js +12 -0
- package/dist/a2ui/BindingContext.js.map +1 -0
- package/dist/a2ui/PresentRenderer.d.ts +11 -0
- package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
- package/dist/a2ui/PresentRenderer.js +39 -0
- package/dist/a2ui/PresentRenderer.js.map +1 -0
- package/dist/a2ui/convert.d.ts +6 -0
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +369 -87
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/dataModel.d.ts +9 -0
- package/dist/a2ui/dataModel.d.ts.map +1 -0
- package/dist/a2ui/dataModel.js +44 -0
- package/dist/a2ui/dataModel.js.map +1 -0
- package/dist/a2ui/reducer.d.ts +5 -0
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +3 -3
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/types.d.ts +8 -2
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui/valueFunctions.d.ts +16 -0
- package/dist/a2ui/valueFunctions.d.ts.map +1 -0
- package/dist/a2ui/valueFunctions.js +332 -0
- package/dist/a2ui/valueFunctions.js.map +1 -0
- package/dist/actionRegistry.d.ts +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/answeredValues.d.ts +16 -0
- package/dist/answeredValues.d.ts.map +1 -0
- package/dist/answeredValues.js +37 -0
- package/dist/answeredValues.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/fieldReferences.d.ts +9 -0
- package/dist/fieldReferences.d.ts.map +1 -0
- package/dist/fieldReferences.js +53 -0
- package/dist/fieldReferences.js.map +1 -0
- package/dist/generativeUIToJSX.js +3 -3
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.server.js +2 -2
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +1 -1
- package/dist/ir.js.map +1 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +12 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +2 -0
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +3 -1
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +3 -1
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.js +58 -7
- package/dist/slack/decodeBlockAction.js.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.js +20 -3
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +187 -19
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +4 -0
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/teams/constants.d.ts +1 -1
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -1
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +4 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.js +6 -1
- package/dist/teams/decodeSubmitData.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +5 -4
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +56 -17
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/types.d.ts +4 -1
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +9 -2
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +116 -19
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +2 -1
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +10 -3
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +22 -5
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +278 -83
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +3 -2
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.js +33 -4
- package/dist/vocabulary/dispatch.js.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +37 -11
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/form.js +3 -3
- package/dist/vocabulary/form.js.map +1 -1
- package/dist/vocabulary/formatValue.d.ts +7 -0
- package/dist/vocabulary/formatValue.d.ts.map +1 -0
- package/dist/vocabulary/formatValue.js +76 -0
- package/dist/vocabulary/formatValue.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +1 -1
- package/dist/vocabulary/interactive.d.ts +137 -8
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +404 -103
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +4 -4
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.js +2 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/dist/vocabulary/text.d.ts +1 -1
- package/package.json +6 -3
- package/src/JSONGenerativeUI.client.tsx +84 -40
- package/src/JSONGenerativeUI.test.tsx +964 -68
- package/src/RadioGroupScope.server.test.tsx +24 -0
- package/src/RadioGroupScope.tsx +34 -0
- package/src/a2ui/BindingContext.ts +26 -0
- package/src/a2ui/PresentRenderer.tsx +83 -0
- package/src/a2ui/convert.test.ts +1982 -145
- package/src/a2ui/convert.ts +677 -137
- package/src/a2ui/dataModel.ts +69 -0
- package/src/a2ui/reducer.test.ts +35 -0
- package/src/a2ui/reducer.ts +5 -4
- package/src/a2ui/types.ts +9 -2
- package/src/a2ui/valueFunctions.test.ts +271 -0
- package/src/a2ui/valueFunctions.ts +523 -0
- package/src/actionRegistry.ts +1 -1
- package/src/answeredValues.test.ts +33 -0
- package/src/answeredValues.tsx +70 -0
- package/src/constants.ts +4 -0
- package/src/fieldReferences.test.ts +66 -0
- package/src/fieldReferences.ts +83 -0
- package/src/generativeUIToJSX.test.ts +18 -0
- package/src/generativeUIToJSX.ts +4 -6
- package/src/index.server.test.ts +106 -0
- package/src/ir.ts +1 -1
- package/src/renderGenerativeUI.test.tsx +14 -5
- package/src/renderGenerativeUI.tsx +30 -1
- package/src/slack/a2uiSlack.test.ts +198 -0
- package/src/slack/constants.ts +3 -0
- package/src/slack/decodeBlockAction.test.ts +98 -0
- package/src/slack/decodeBlockAction.ts +110 -17
- package/src/slack/fromSlackBlocks.test.ts +222 -3
- package/src/slack/fromSlackBlocks.ts +53 -2
- package/src/slack/toSlackBlocks.test.ts +682 -3
- package/src/slack/toSlackBlocks.ts +374 -23
- package/src/slack/types.ts +4 -0
- package/src/teams/constants.ts +1 -1
- package/src/teams/decodeSubmitData.test.ts +33 -0
- package/src/teams/decodeSubmitData.ts +9 -1
- package/src/teams/toAdaptiveCard.test.ts +219 -0
- package/src/teams/toAdaptiveCard.ts +93 -19
- package/src/teams/types.ts +4 -1
- package/src/vocabulary/alert.test.tsx +288 -7
- package/src/vocabulary/alert.tsx +168 -26
- package/src/vocabulary/collectFormValues.test.ts +28 -1
- package/src/vocabulary/collectFormValues.ts +25 -5
- package/src/vocabulary/data.test.tsx +248 -6
- package/src/vocabulary/data.tsx +398 -100
- package/src/vocabulary/dispatch.ts +63 -2
- package/src/vocabulary/fact.test.tsx +67 -0
- package/src/vocabulary/fact.tsx +40 -9
- package/src/vocabulary/form.tsx +7 -2
- package/src/vocabulary/formatValue.test.ts +72 -0
- package/src/vocabulary/formatValue.ts +127 -0
- package/src/vocabulary/index.test.ts +2 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
- package/src/vocabulary/interactive.dom.test.tsx +1699 -0
- package/src/vocabulary/interactive.test.tsx +60 -4
- package/src/vocabulary/interactive.tsx +770 -151
- package/src/vocabulary/layout.test.tsx +6 -4
- package/src/vocabulary/layout.tsx +13 -2
- package/src/vocabulary/list.tsx +2 -2
|
@@ -1,19 +1,80 @@
|
|
|
1
|
+
import { hasFieldReference, resolveFieldReferences } from "../fieldReferences";
|
|
1
2
|
import type { Action } from "../ir";
|
|
2
3
|
import type { GenerativeUIDispatch } from "../types";
|
|
4
|
+
import type { A2uiBinding } from "../a2ui/BindingContext";
|
|
5
|
+
import { equalData } from "../a2ui/dataModel";
|
|
6
|
+
import { CHECKBOX_GROUP_ATTR, FIELD_NAME_ATTR } from "../constants";
|
|
7
|
+
import {
|
|
8
|
+
collectFormValues,
|
|
9
|
+
type FormControlElementLike,
|
|
10
|
+
} from "./collectFormValues";
|
|
3
11
|
|
|
4
12
|
export const actionAttr = (a: Action | undefined): string | undefined =>
|
|
5
13
|
a ? JSON.stringify(a) : undefined;
|
|
6
14
|
|
|
15
|
+
const fieldValues = (
|
|
16
|
+
source: Element,
|
|
17
|
+
bindings?: ReadonlyMap<string, A2uiBinding>,
|
|
18
|
+
): Record<string, unknown> => {
|
|
19
|
+
const scope = source.closest('form[data-aui], [data-aui="root"]');
|
|
20
|
+
if (!scope) return {};
|
|
21
|
+
const root = scope.closest('[data-aui="root"]');
|
|
22
|
+
const controls = Array.from(
|
|
23
|
+
scope.querySelectorAll<
|
|
24
|
+
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
|
25
|
+
>("input, select, textarea"),
|
|
26
|
+
).filter((control) => control.closest('[data-aui="root"]') === root);
|
|
27
|
+
const values = collectFormValues(
|
|
28
|
+
controls as ArrayLike<FormControlElementLike>,
|
|
29
|
+
);
|
|
30
|
+
if (!bindings) return values;
|
|
31
|
+
const groups = new Map<string, Map<Element, FormControlElementLike[]>>();
|
|
32
|
+
for (const control of controls) {
|
|
33
|
+
const name = control.getAttribute(FIELD_NAME_ATTR) ?? control.name;
|
|
34
|
+
if (!bindings.has(name)) continue;
|
|
35
|
+
const field =
|
|
36
|
+
groups.get(name) ?? new Map<Element, FormControlElementLike[]>();
|
|
37
|
+
groups.set(name, field);
|
|
38
|
+
const group =
|
|
39
|
+
control.type === "radio" || control.hasAttribute(CHECKBOX_GROUP_ATTR)
|
|
40
|
+
? (control.closest("fieldset") ?? control)
|
|
41
|
+
: control;
|
|
42
|
+
const elements = field.get(group) ?? [];
|
|
43
|
+
elements.push(control);
|
|
44
|
+
field.set(group, elements);
|
|
45
|
+
}
|
|
46
|
+
for (const [name, field] of groups) {
|
|
47
|
+
const candidates = [...field.values()]
|
|
48
|
+
.map((elements) => collectFormValues(elements)[name])
|
|
49
|
+
.filter((value) => value !== undefined);
|
|
50
|
+
if (candidates.length > 0)
|
|
51
|
+
values[name] =
|
|
52
|
+
candidates.find(
|
|
53
|
+
(value) => !equalData(value, bindings.get(name)!.value),
|
|
54
|
+
) ?? candidates[0];
|
|
55
|
+
}
|
|
56
|
+
return values;
|
|
57
|
+
};
|
|
58
|
+
|
|
7
59
|
/**
|
|
8
|
-
* Fires `$action` through `$dispatch` when both are present, merging a runtime value into the payload under the reserved `$input` key (not `value`) so the user's input never clobbers a model-supplied `value` field. No-op when no registry is wired. The returned promise from an async handler is caught and re-thrown on a microtask so rejections surface rather than going unhandled.
|
|
60
|
+
* Fires `$action` through `$dispatch` when both are present, merging a runtime value into the payload under the reserved `$input` key (not `value`) so the user's input never clobbers a model-supplied `value` field. Each `{ "$field": name }` inside `$action` first resolves to the current value of the control with that `name` in the vocabulary form or generative UI root nearest to `source`, collected the way a `Form` collects it; a reference whose control is missing or holds no value resolves to its `fallback`, or is dropped without one. No-op when no registry is wired. The returned promise from an async handler is caught and re-thrown on a microtask so rejections surface rather than going unhandled.
|
|
9
61
|
*/
|
|
10
62
|
export const fire = (
|
|
11
63
|
$action: Action | undefined,
|
|
12
64
|
$dispatch: GenerativeUIDispatch | undefined,
|
|
13
65
|
input?: unknown,
|
|
66
|
+
source?: Element,
|
|
67
|
+
bindings?: ReadonlyMap<string, A2uiBinding>,
|
|
14
68
|
) => {
|
|
15
69
|
if (!$action || !$dispatch) return;
|
|
16
|
-
const
|
|
70
|
+
const action =
|
|
71
|
+
source && hasFieldReference($action)
|
|
72
|
+
? (resolveFieldReferences(
|
|
73
|
+
$action,
|
|
74
|
+
fieldValues(source, bindings),
|
|
75
|
+
) as Action)
|
|
76
|
+
: $action;
|
|
77
|
+
const payload = input === undefined ? action : { ...action, $input: input };
|
|
17
78
|
void Promise.resolve($dispatch(payload)).catch((error) => {
|
|
18
79
|
queueMicrotask(() => {
|
|
19
80
|
throw error;
|
|
@@ -24,4 +24,71 @@ describe("factVocabulary", () => {
|
|
|
24
24
|
'<dl data-aui="fact"><dt data-aui="fact-label"></dt><dd data-aui="fact-value"></dd></dl>',
|
|
25
25
|
);
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
it("Fact renders a good or bad delta with an arrow", () => {
|
|
29
|
+
expect(
|
|
30
|
+
render({
|
|
31
|
+
$type: "Fact",
|
|
32
|
+
label: "Revenue",
|
|
33
|
+
value: "$12.4k",
|
|
34
|
+
delta: "+8%",
|
|
35
|
+
}),
|
|
36
|
+
).toContain(
|
|
37
|
+
'<span data-aui="fact-delta" data-aui-trend="up" data-aui-tone="good">↑ 8%</span>',
|
|
38
|
+
);
|
|
39
|
+
expect(
|
|
40
|
+
render({
|
|
41
|
+
$type: "Fact",
|
|
42
|
+
label: "Cost",
|
|
43
|
+
value: "$12.4k",
|
|
44
|
+
delta: "-8%",
|
|
45
|
+
upIsGood: false,
|
|
46
|
+
}),
|
|
47
|
+
).toContain(
|
|
48
|
+
'<span data-aui="fact-delta" data-aui-trend="down" data-aui-tone="good">↓ 8%</span>',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("Fact reads a typographic minus as a downward delta", () => {
|
|
53
|
+
expect(
|
|
54
|
+
render({
|
|
55
|
+
$type: "Fact",
|
|
56
|
+
label: "Latency",
|
|
57
|
+
value: "120 ms",
|
|
58
|
+
delta: "\u22123%",
|
|
59
|
+
}),
|
|
60
|
+
).toContain('data-aui-trend="down" data-aui-tone="bad">↓ 3%</span>');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("Fact infers a zero delta as flat", () => {
|
|
64
|
+
expect(
|
|
65
|
+
render({
|
|
66
|
+
$type: "Fact",
|
|
67
|
+
label: "Revenue",
|
|
68
|
+
value: "$12.4k",
|
|
69
|
+
delta: "+0.0%",
|
|
70
|
+
}),
|
|
71
|
+
).toContain('data-aui-trend="flat" data-aui-tone="neutral">→ +0.0%</span>');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("Fact lets trend override the delta sign and renders flat deltas neutrally", () => {
|
|
75
|
+
expect(
|
|
76
|
+
render({
|
|
77
|
+
$type: "Fact",
|
|
78
|
+
label: "Revenue",
|
|
79
|
+
value: "$12.4k",
|
|
80
|
+
delta: "-8%",
|
|
81
|
+
trend: "up",
|
|
82
|
+
}),
|
|
83
|
+
).toContain('data-aui-trend="up" data-aui-tone="good">↑ -8%</span>');
|
|
84
|
+
expect(
|
|
85
|
+
render({
|
|
86
|
+
$type: "Fact",
|
|
87
|
+
label: "Revenue",
|
|
88
|
+
value: "$12.4k",
|
|
89
|
+
delta: "0%",
|
|
90
|
+
trend: "flat",
|
|
91
|
+
}),
|
|
92
|
+
).toContain('data-aui-trend="flat" data-aui-tone="neutral">→ 0%</span>');
|
|
93
|
+
});
|
|
27
94
|
});
|
package/src/vocabulary/fact.tsx
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { GenerativeUILibrary } from "../types";
|
|
3
|
+
import { factTrend, formatFactDelta } from "./formatValue";
|
|
3
4
|
import { toTextContent } from "./toTextContent";
|
|
4
5
|
|
|
6
|
+
const factTone = (
|
|
7
|
+
trend: "up" | "down" | "flat",
|
|
8
|
+
upIsGood: unknown,
|
|
9
|
+
): "good" | "bad" | "neutral" => {
|
|
10
|
+
if (trend === "flat") return "neutral";
|
|
11
|
+
const positive = upIsGood !== false;
|
|
12
|
+
const isGood = trend === "up" ? positive : !positive;
|
|
13
|
+
return isGood ? "good" : "bad";
|
|
14
|
+
};
|
|
15
|
+
|
|
5
16
|
export const factVocabulary = {
|
|
6
17
|
Fact: {
|
|
7
18
|
description:
|
|
@@ -9,15 +20,35 @@ export const factVocabulary = {
|
|
|
9
20
|
properties: z.object({
|
|
10
21
|
label: z.string().describe("The fact label (key)."),
|
|
11
22
|
value: z.string().describe("The fact value."),
|
|
23
|
+
delta: z.string().optional().describe("Change from before."),
|
|
24
|
+
trend: z
|
|
25
|
+
.enum(["up", "down", "flat"])
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("Change direction."),
|
|
28
|
+
upIsGood: z.boolean().optional().describe("Whether up is good."),
|
|
12
29
|
}),
|
|
13
|
-
render: ({ label, value, children }) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
render: ({ label, value, delta, trend, upIsGood, children }) => {
|
|
31
|
+
const direction = factTrend(delta, trend);
|
|
32
|
+
const tone = factTone(direction, upIsGood);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<dl data-aui="fact">
|
|
36
|
+
<dt data-aui="fact-label">{toTextContent(label)}</dt>
|
|
37
|
+
<dd data-aui="fact-value">
|
|
38
|
+
{toTextContent(value)}
|
|
39
|
+
{typeof delta === "string" ? (
|
|
40
|
+
<span
|
|
41
|
+
data-aui="fact-delta"
|
|
42
|
+
data-aui-trend={direction}
|
|
43
|
+
data-aui-tone={tone}
|
|
44
|
+
>
|
|
45
|
+
{formatFactDelta(delta, direction)}
|
|
46
|
+
</span>
|
|
47
|
+
) : null}
|
|
48
|
+
{children}
|
|
49
|
+
</dd>
|
|
50
|
+
</dl>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
22
53
|
},
|
|
23
54
|
} satisfies GenerativeUILibrary;
|
package/src/vocabulary/form.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { collectFormValuesFromEvent } from "./collectFormValues";
|
|
|
6
6
|
export const formVocabulary = {
|
|
7
7
|
Form: {
|
|
8
8
|
description:
|
|
9
|
-
"Wraps named child controls (Select/Input/Checkbox/RadioGroup/DatePicker with a `name`). Carries `$action`; on submit it fires with every named control's value, keyed by `name`.",
|
|
9
|
+
"Wraps named child controls (Select/Input/Checkbox/RadioGroup/CheckboxGroup/DatePicker with a `name`). Carries `$action`; on submit it fires with every named control's value, keyed by `name`.",
|
|
10
10
|
properties: z.object({
|
|
11
11
|
gap: z
|
|
12
12
|
.number()
|
|
@@ -24,7 +24,12 @@ export const formVocabulary = {
|
|
|
24
24
|
data-aui-action={actionAttr($action)}
|
|
25
25
|
onSubmit={(event) => {
|
|
26
26
|
event.preventDefault();
|
|
27
|
-
fire(
|
|
27
|
+
fire(
|
|
28
|
+
$action,
|
|
29
|
+
$dispatch,
|
|
30
|
+
collectFormValuesFromEvent(event),
|
|
31
|
+
event.currentTarget,
|
|
32
|
+
);
|
|
28
33
|
}}
|
|
29
34
|
>
|
|
30
35
|
{children}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { factTrend, formatValue } from "./formatValue";
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.restoreAllMocks();
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
describe("formatValue", () => {
|
|
9
|
+
it.each(["0", "0%", "+0.0%", "−0", "$0"])(
|
|
10
|
+
"infers %s as a flat fact delta",
|
|
11
|
+
(delta) => {
|
|
12
|
+
expect(factTrend(delta, undefined)).toBe("flat");
|
|
13
|
+
},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
it("formats numbers, currency, percentages, and dates", () => {
|
|
17
|
+
expect(formatValue(1234.567, { kind: "number", decimals: 2 })).toBe(
|
|
18
|
+
"1,234.57",
|
|
19
|
+
);
|
|
20
|
+
expect(
|
|
21
|
+
formatValue(1234.5, { kind: "currency", currency: "USD", decimals: 2 }),
|
|
22
|
+
).toBe("$1,234.50");
|
|
23
|
+
expect(formatValue(0.1234, { kind: "percent", decimals: 1 })).toBe("12.3%");
|
|
24
|
+
expect(formatValue("2024-01-02", { kind: "date" })).toBe("Jan 2, 2024");
|
|
25
|
+
expect(formatValue(0, { kind: "date" })).toBe(
|
|
26
|
+
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(0),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps a date-only value on its calendar day west of UTC", () => {
|
|
31
|
+
const DateTimeFormat = Intl.DateTimeFormat;
|
|
32
|
+
vi.spyOn(Intl, "DateTimeFormat").mockImplementation(
|
|
33
|
+
function (locales, options) {
|
|
34
|
+
return new DateTimeFormat(locales, {
|
|
35
|
+
timeZone: "America/Los_Angeles",
|
|
36
|
+
...options,
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
expect(formatValue("2026-09-26", { kind: "date" })).toBe("Sep 26, 2026");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps impossible ISO calendar dates as raw text", () => {
|
|
45
|
+
expect(formatValue("2024-02-30", { kind: "date" })).toBe("2024-02-30");
|
|
46
|
+
expect(formatValue("2023-02-29", { kind: "date" })).toBe("2023-02-29");
|
|
47
|
+
expect(formatValue("2024-04-31", { kind: "date" })).toBe("2024-04-31");
|
|
48
|
+
expect(formatValue("2024-02-29", { kind: "date" })).toBe("Feb 29, 2024");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("clamps decimal precision", () => {
|
|
52
|
+
expect(formatValue(1.234, { kind: "number", decimals: -2 })).toBe("1");
|
|
53
|
+
expect(formatValue(1.234, { kind: "number", decimals: 30 })).toBe(
|
|
54
|
+
new Intl.NumberFormat("en-US", {
|
|
55
|
+
minimumFractionDigits: 20,
|
|
56
|
+
maximumFractionDigits: 20,
|
|
57
|
+
}).format(1.234),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps wrong-typed values and invalid formats as raw text", () => {
|
|
62
|
+
expect(formatValue("1234", { kind: "number" })).toBe("1234");
|
|
63
|
+
expect(formatValue("0.2", { kind: "percent" })).toBe("0.2");
|
|
64
|
+
expect(formatValue("not a date", { kind: "date" })).toBe("not a date");
|
|
65
|
+
expect(() =>
|
|
66
|
+
formatValue(12, { kind: "currency", currency: "not-a-currency" }),
|
|
67
|
+
).not.toThrow();
|
|
68
|
+
expect(
|
|
69
|
+
formatValue(12, { kind: "currency", currency: "not-a-currency" }),
|
|
70
|
+
).toBe("12");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
type TableFormat = {
|
|
2
|
+
kind: "number" | "currency" | "percent" | "date";
|
|
3
|
+
currency?: string;
|
|
4
|
+
decimals?: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
8
|
+
|
|
9
|
+
const ISO_DATE_PATTERN =
|
|
10
|
+
/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
11
|
+
|
|
12
|
+
type FactTrend = "up" | "down" | "flat";
|
|
13
|
+
|
|
14
|
+
const toRawText = (value: unknown): string =>
|
|
15
|
+
typeof value === "string" ||
|
|
16
|
+
typeof value === "number" ||
|
|
17
|
+
typeof value === "boolean"
|
|
18
|
+
? String(value)
|
|
19
|
+
: "";
|
|
20
|
+
|
|
21
|
+
const isTableFormat = (format: unknown): format is TableFormat =>
|
|
22
|
+
format !== null &&
|
|
23
|
+
typeof format === "object" &&
|
|
24
|
+
"kind" in format &&
|
|
25
|
+
(format.kind === "number" ||
|
|
26
|
+
format.kind === "currency" ||
|
|
27
|
+
format.kind === "percent" ||
|
|
28
|
+
format.kind === "date");
|
|
29
|
+
|
|
30
|
+
const fractionDigits = (decimals: unknown): number | undefined => {
|
|
31
|
+
if (typeof decimals !== "number" || !Number.isFinite(decimals)) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return Math.min(20, Math.max(0, Math.trunc(decimals)));
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const isCalendarDate = (value: string): boolean => {
|
|
38
|
+
const [year, month, day] = value.slice(0, 10).split("-").map(Number);
|
|
39
|
+
if (year === undefined || month === undefined || day === undefined) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const daysInMonth =
|
|
43
|
+
month === 2
|
|
44
|
+
? year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
|
|
45
|
+
? 29
|
|
46
|
+
: 28
|
|
47
|
+
: [4, 6, 9, 11].includes(month)
|
|
48
|
+
? 30
|
|
49
|
+
: 31;
|
|
50
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const dateFromValue = (value: unknown): Date | undefined => {
|
|
54
|
+
if (typeof value === "number") {
|
|
55
|
+
const date = new Date(value);
|
|
56
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
57
|
+
}
|
|
58
|
+
if (typeof value !== "string" || !ISO_DATE_PATTERN.test(value)) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (!isCalendarDate(value)) return undefined;
|
|
62
|
+
const date = new Date(value);
|
|
63
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const factTrend = (delta: unknown, trend: unknown): FactTrend => {
|
|
67
|
+
if (trend === "down" || trend === "flat" || trend === "up") return trend;
|
|
68
|
+
if (typeof delta === "string" && /\d/.test(delta) && !/[1-9]/.test(delta)) {
|
|
69
|
+
return "flat";
|
|
70
|
+
}
|
|
71
|
+
return typeof delta === "string" && /^[-\u2212]/.test(delta) ? "down" : "up";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const formatFactDelta = (delta: string, trend: FactTrend): string => {
|
|
75
|
+
const arrow = trend === "up" ? "↑" : trend === "down" ? "↓" : "→";
|
|
76
|
+
const text =
|
|
77
|
+
trend === "up"
|
|
78
|
+
? delta.replace(/^\+/, "")
|
|
79
|
+
: trend === "down"
|
|
80
|
+
? delta.replace(/^[-\u2212]/, "")
|
|
81
|
+
: delta;
|
|
82
|
+
return `${arrow} ${text}`;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const isNumericTableFormat = (format: unknown): boolean =>
|
|
86
|
+
isTableFormat(format) && format.kind !== "date";
|
|
87
|
+
|
|
88
|
+
export const formatValue = (value: unknown, format: unknown): string => {
|
|
89
|
+
const raw = toRawText(value);
|
|
90
|
+
try {
|
|
91
|
+
if (!isTableFormat(format)) return raw;
|
|
92
|
+
|
|
93
|
+
if (format.kind === "date") {
|
|
94
|
+
const date = dateFromValue(value);
|
|
95
|
+
return date === undefined
|
|
96
|
+
? raw
|
|
97
|
+
: new Intl.DateTimeFormat("en-US", {
|
|
98
|
+
dateStyle: "medium",
|
|
99
|
+
...(typeof value === "string" && DATE_ONLY_PATTERN.test(value)
|
|
100
|
+
? { timeZone: "UTC" }
|
|
101
|
+
: {}),
|
|
102
|
+
}).format(date);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof value !== "number") return raw;
|
|
106
|
+
|
|
107
|
+
const decimals = fractionDigits(format.decimals);
|
|
108
|
+
const digitOptions =
|
|
109
|
+
decimals === undefined
|
|
110
|
+
? {}
|
|
111
|
+
: { minimumFractionDigits: decimals, maximumFractionDigits: decimals };
|
|
112
|
+
if (format.kind === "currency") {
|
|
113
|
+
if (typeof format.currency !== "string") return raw;
|
|
114
|
+
return new Intl.NumberFormat("en-US", {
|
|
115
|
+
...digitOptions,
|
|
116
|
+
style: "currency",
|
|
117
|
+
currency: format.currency,
|
|
118
|
+
}).format(value);
|
|
119
|
+
}
|
|
120
|
+
return new Intl.NumberFormat("en-US", {
|
|
121
|
+
...digitOptions,
|
|
122
|
+
...(format.kind === "percent" ? { style: "percent" } : {}),
|
|
123
|
+
}).format(value);
|
|
124
|
+
} catch {
|
|
125
|
+
return raw;
|
|
126
|
+
}
|
|
127
|
+
};
|