@startsimpli/ui 0.4.95 → 0.4.97
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 +7 -7
- package/src/__tests__/shipped-types.test.ts +80 -0
- package/src/components/dashboard/DeltaPill.tsx +5 -1
- package/src/components/dashboard/DonutChart.tsx +7 -4
- package/src/components/dashboard/MetricCard.tsx +13 -3
- package/src/components/dashboard/Numeral.tsx +50 -0
- package/src/components/dashboard/PipelineFunnel.tsx +10 -2
- package/src/components/dashboard/StatTile.tsx +6 -1
- package/src/components/dashboard/TopCampaigns.tsx +7 -4
- package/src/components/dashboard/__tests__/display-primitives.test.tsx +4 -1
- package/src/components/dashboard/__tests__/numeral-rtl.test.tsx +132 -0
- package/src/components/dashboard/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.97",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
47
47
|
"@startsimpli/api": "^0.5.54",
|
|
48
48
|
"@startsimpli/auth": "^0.4.35",
|
|
49
|
-
"@startsimpli/i18n": "^0.1.
|
|
49
|
+
"@startsimpli/i18n": "^0.1.2"
|
|
50
50
|
},
|
|
51
51
|
"peerDependenciesMeta": {
|
|
52
52
|
"@startsimpli/api": {
|
|
@@ -67,6 +67,9 @@
|
|
|
67
67
|
},
|
|
68
68
|
"dependencies": {
|
|
69
69
|
"@hello-pangea/dnd": "^18.0.1",
|
|
70
|
+
"@types/dagre": "^0.7.52",
|
|
71
|
+
"@types/react-big-calendar": "^1.16.3",
|
|
72
|
+
"@types/react-syntax-highlighter": "^15.5.13",
|
|
70
73
|
"@xterm/addon-fit": "^0.10.0",
|
|
71
74
|
"@xyflow/react": "^12.10.2",
|
|
72
75
|
"dagre": "^0.8.5",
|
|
@@ -107,12 +110,9 @@
|
|
|
107
110
|
"@testing-library/react": "^16.3.2",
|
|
108
111
|
"@testing-library/user-event": "^14.6.1",
|
|
109
112
|
"@types/jest": "^30.0.0",
|
|
110
|
-
"@types/dagre": "^0.7.52",
|
|
111
113
|
"@types/node": "^20.19.39",
|
|
112
114
|
"@types/react": "^19.2.14",
|
|
113
|
-
"@types/react-big-calendar": "^1.16.3",
|
|
114
115
|
"@types/react-dom": "^19.2.3",
|
|
115
|
-
"@types/react-syntax-highlighter": "^15.5.13",
|
|
116
116
|
"eslint": "^8.57.1",
|
|
117
117
|
"identity-obj-proxy": "^3.0.0",
|
|
118
118
|
"jest": "^30.3.0",
|
|
@@ -120,8 +120,8 @@
|
|
|
120
120
|
"tailwindcss": "^3.4.19",
|
|
121
121
|
"tsup": "^8.5.1",
|
|
122
122
|
"typescript": "^6.0.3",
|
|
123
|
-
"@startsimpli/
|
|
124
|
-
"@startsimpli/
|
|
123
|
+
"@startsimpli/i18n": "0.1.2",
|
|
124
|
+
"@startsimpli/realtime": "0.0.2"
|
|
125
125
|
},
|
|
126
126
|
"keywords": [
|
|
127
127
|
"ui",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-wide guard: types needed to COMPILE shipped source are runtime deps
|
|
3
|
+
* (startsim-lglc).
|
|
4
|
+
*
|
|
5
|
+
* This package publishes TypeScript SOURCE — `exports` points at `./src/*.ts`
|
|
6
|
+
* and `files` ships `src`. So a consumer's `tsc` does not read a built `.d.ts`
|
|
7
|
+
* from us; it compiles our `.tsx` files inside their `node_modules`. Two
|
|
8
|
+
* consequences that make the usual "types are a dev concern" split wrong here:
|
|
9
|
+
*
|
|
10
|
+
* 1. `skipLibCheck` does not save anyone. Every Next app sets it, and it would
|
|
11
|
+
* hide a bad type in a shipped `.d.ts` — but it only skips `.d.ts` files,
|
|
12
|
+
* and ours are `.tsx`. Our type errors land in the consumer's build.
|
|
13
|
+
* 2. Anything our source needs in order to type-check is, by definition, part
|
|
14
|
+
* of what we ship. If `@types/x` lives in devDependencies, the consumer
|
|
15
|
+
* installs `x` (a real dependency) without its types and gets TS7016
|
|
16
|
+
* 'implicitly has an any type' pointing inside node_modules/@startsimpli/ui
|
|
17
|
+
* — an error in OUR code that they cannot fix.
|
|
18
|
+
*
|
|
19
|
+
* That was not hypothetical: every consumer's type-check was permanently
|
|
20
|
+
* one-error-dirty on `react-big-calendar`, so a real regression could hide in
|
|
21
|
+
* the noise. `dagre` (via `@startsimpli/ui/workflows`) and
|
|
22
|
+
* `react-syntax-highlighter` (via `@startsimpli/ui/blog`) were the same bug,
|
|
23
|
+
* latent behind entry points that consumer happened not to import.
|
|
24
|
+
*
|
|
25
|
+
* The rule: if `x` is a runtime dependency and we declare `@types/x` at all,
|
|
26
|
+
* `@types/x` belongs in `dependencies` too. Types for a PEER dependency (react,
|
|
27
|
+
* react-dom) stay in devDependencies — the consumer supplies those and their
|
|
28
|
+
* types. Types for tooling (node, jest) stay in devDependencies — no shipped
|
|
29
|
+
* source imports them.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import * as fs from 'fs'
|
|
33
|
+
import * as path from 'path'
|
|
34
|
+
|
|
35
|
+
const PKG = JSON.parse(
|
|
36
|
+
fs.readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8'),
|
|
37
|
+
) as {
|
|
38
|
+
dependencies: Record<string, string>
|
|
39
|
+
devDependencies: Record<string, string>
|
|
40
|
+
peerDependencies: Record<string, string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `@types/react-dom` -> `react-dom`; `@types/scope__pkg` -> `@scope/pkg`. */
|
|
44
|
+
function runtimeNameFor(typesPkg: string): string {
|
|
45
|
+
const stem = typesPkg.slice('@types/'.length)
|
|
46
|
+
return stem.includes('__') ? `@${stem.replace('__', '/')}` : stem
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('types needed to compile shipped source ship with it', () => {
|
|
50
|
+
it('every @types for a runtime dependency is itself a runtime dependency', () => {
|
|
51
|
+
const misplaced = Object.keys(PKG.devDependencies)
|
|
52
|
+
.filter((name) => name.startsWith('@types/'))
|
|
53
|
+
.filter((name) => PKG.dependencies[runtimeNameFor(name)] !== undefined)
|
|
54
|
+
|
|
55
|
+
expect(misplaced).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('the untyped runtime deps our source imports carry their types', () => {
|
|
59
|
+
// These three packages ship no declarations of their own, and shipped
|
|
60
|
+
// source imports all three. Without these entries a consumer's tsc reports
|
|
61
|
+
// TS7016 inside node_modules/@startsimpli/ui.
|
|
62
|
+
for (const types of [
|
|
63
|
+
'@types/dagre',
|
|
64
|
+
'@types/react-big-calendar',
|
|
65
|
+
'@types/react-syntax-highlighter',
|
|
66
|
+
]) {
|
|
67
|
+
expect(PKG.dependencies[types]).toBeDefined()
|
|
68
|
+
expect(PKG.devDependencies[types]).toBeUndefined()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('types for peer dependencies stay a dev concern', () => {
|
|
73
|
+
// The consumer owns react/react-dom, so it owns their types. Shipping these
|
|
74
|
+
// would risk a second, conflicting copy of the React types in their build.
|
|
75
|
+
for (const types of ['@types/react', '@types/react-dom']) {
|
|
76
|
+
expect(PKG.devDependencies[types]).toBeDefined()
|
|
77
|
+
expect(PKG.dependencies[types]).toBeUndefined()
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Numeral } from './Numeral'
|
|
3
4
|
|
|
4
5
|
export type DeltaDirection = 'up' | 'down' | 'neutral'
|
|
5
6
|
|
|
@@ -112,7 +113,10 @@ export function DeltaPill({
|
|
|
112
113
|
>
|
|
113
114
|
<DirectionArrow direction={resolved} />
|
|
114
115
|
<span className="sr-only">{DIRECTION_WORD[resolved]}</span>
|
|
115
|
-
|
|
116
|
+
{/* The pill itself is free to mirror — in RTL the arrow belongs on the
|
|
117
|
+
start side like any other leading icon. Only the figure is pinned,
|
|
118
|
+
because `+2.8%` must not become `%2.8+`. */}
|
|
119
|
+
<Numeral>{text}</Numeral>
|
|
116
120
|
{label ? <span className="opacity-80">{label}</span> : null}
|
|
117
121
|
</span>
|
|
118
122
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Numeral } from './Numeral'
|
|
3
4
|
|
|
4
5
|
export interface DonutSegment {
|
|
5
6
|
label: string
|
|
@@ -158,9 +159,9 @@ export function DonutChart({
|
|
|
158
159
|
{hasCenter && (
|
|
159
160
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center px-3 text-center">
|
|
160
161
|
{centerValue !== undefined && (
|
|
161
|
-
<
|
|
162
|
+
<Numeral className="text-xl font-semibold tracking-tight text-foreground">
|
|
162
163
|
{centerValue}
|
|
163
|
-
</
|
|
164
|
+
</Numeral>
|
|
164
165
|
)}
|
|
165
166
|
{centerLabel !== undefined && (
|
|
166
167
|
<span className="mt-0.5 text-xs text-muted-foreground">
|
|
@@ -203,9 +204,11 @@ export function DonutChart({
|
|
|
203
204
|
{segment.label}
|
|
204
205
|
</span>
|
|
205
206
|
</span>
|
|
206
|
-
|
|
207
|
+
{/* `formatLegendValue` is where a consumer adds a currency symbol
|
|
208
|
+
or a '%', so the formatted result is exactly what needs pinning. */}
|
|
209
|
+
<Numeral className="shrink-0 font-medium text-foreground">
|
|
207
210
|
{formatLegendValue ? formatLegendValue(segment) : segment.value}
|
|
208
|
-
</
|
|
211
|
+
</Numeral>
|
|
209
212
|
</li>
|
|
210
213
|
))}
|
|
211
214
|
</ul>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import { cn } from '../../lib/utils'
|
|
3
|
+
import { Numeral } from './Numeral'
|
|
3
4
|
import { SparklineTrend } from './SparklineTrend'
|
|
4
5
|
|
|
5
6
|
export interface MetricCardTrend {
|
|
@@ -57,7 +58,15 @@ export function MetricCard({
|
|
|
57
58
|
{/* Value */}
|
|
58
59
|
<div className="flex items-baseline justify-between">
|
|
59
60
|
<p className="text-3xl font-bold text-gray-900">
|
|
60
|
-
{
|
|
61
|
+
{/* 'Error' is prose and follows the document; only the figure is
|
|
62
|
+
pinned, and the placeholder dash is neutral either way. */}
|
|
63
|
+
{isLoading ? (
|
|
64
|
+
'\u2014'
|
|
65
|
+
) : error ? (
|
|
66
|
+
'Error'
|
|
67
|
+
) : (
|
|
68
|
+
<Numeral>{value}</Numeral>
|
|
69
|
+
)}
|
|
61
70
|
</p>
|
|
62
71
|
|
|
63
72
|
{/* Trend Indicator */}
|
|
@@ -124,9 +133,10 @@ function TrendIndicator({ trend }: { trend: MetricCardTrend }) {
|
|
|
124
133
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
|
|
125
134
|
</svg>
|
|
126
135
|
)}
|
|
127
|
-
|
|
136
|
+
{/* Sign and '%' are both bidi-neutral — unpinned, '+12%' reorders. */}
|
|
137
|
+
<Numeral>
|
|
128
138
|
{trend.value > 0 ? '+' : ''}{trend.value}%
|
|
129
|
-
</
|
|
139
|
+
</Numeral>
|
|
130
140
|
</div>
|
|
131
141
|
)
|
|
132
142
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils'
|
|
3
|
+
|
|
4
|
+
export interface NumeralProps
|
|
5
|
+
extends Omit<React.ComponentPropsWithoutRef<'span'>, 'dir'> {
|
|
6
|
+
children: React.ReactNode
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A figure, pinned left-to-right (startsim-768w.35.6).
|
|
11
|
+
*
|
|
12
|
+
* Digits are strongly left-to-right in Unicode, so `1,204` survives an RTL
|
|
13
|
+
* paragraph on its own. The characters that do NOT survive are the ones a
|
|
14
|
+
* component adds AROUND the digits — the sign in a delta, the `%`, the
|
|
15
|
+
* parentheses of a secondary figure, a currency symbol. Those are bidi-NEUTRAL:
|
|
16
|
+
* they take their direction from whatever surrounds them, so in an RTL document
|
|
17
|
+
* the same `+2.8K (+0.03%)` reorders into `(0.13%+) M1.1+`. The number is not
|
|
18
|
+
* wrong, the punctuation has migrated around it, which is worse — it still
|
|
19
|
+
* reads as a number, just a different one.
|
|
20
|
+
*
|
|
21
|
+
* A `dir` attribute is the fix, and it is enough: the HTML rendering spec gives
|
|
22
|
+
* any element carrying `dir` a `unicode-bidi: isolate`, so the span both sets
|
|
23
|
+
* the direction of its own contents and stops the surrounding paragraph from
|
|
24
|
+
* reaching in and reordering them.
|
|
25
|
+
*
|
|
26
|
+
* This belongs in the COMPONENT, not the call site. The component is the only
|
|
27
|
+
* thing that knows it is about to emit a sign or a `%`; a caller passing
|
|
28
|
+
* `value={0.03}` has no reason to suspect it needs to wrap the result. Before
|
|
29
|
+
* this existed mcr-web hand-wrapped essentially every figure across six artist
|
|
30
|
+
* screens, and the next app to go RTL would have repeated that sweep — with the
|
|
31
|
+
* one site everybody misses shipping a plausible, wrong number.
|
|
32
|
+
*
|
|
33
|
+
* In an LTR document this renders identically to a bare span: `dir="ltr"`
|
|
34
|
+
* inside an already-LTR paragraph changes nothing. The isolation is only ever
|
|
35
|
+
* load-bearing in RTL.
|
|
36
|
+
*
|
|
37
|
+
* Exported so consumers can pin figures this package does not render for them —
|
|
38
|
+
* a currency cell in their own table, a hand-built hero number.
|
|
39
|
+
*
|
|
40
|
+
* <Numeral>{formatCurrency(total)}</Numeral>
|
|
41
|
+
*/
|
|
42
|
+
export function Numeral({ children, className, ...rest }: NumeralProps) {
|
|
43
|
+
return (
|
|
44
|
+
// `dir` is written after the spread deliberately: a caller must not be able
|
|
45
|
+
// to pass dir="rtl" and quietly undo the one guarantee this component makes.
|
|
46
|
+
<span {...rest} className={cn('tabular-nums', className)} dir="ltr">
|
|
47
|
+
{children}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Card } from '../ui/card';
|
|
2
2
|
import { TrendingDown } from 'lucide-react';
|
|
3
|
+
import { Numeral } from './Numeral';
|
|
3
4
|
|
|
4
5
|
export interface FunnelStage {
|
|
5
6
|
stage: string;
|
|
@@ -69,11 +70,16 @@ export function PipelineFunnel({
|
|
|
69
70
|
{stage.stage}
|
|
70
71
|
</span>
|
|
71
72
|
<div className="flex items-center gap-3">
|
|
73
|
+
{/* Not pinned, deliberately. A bare trailing '%' is a bidi
|
|
74
|
+
European Terminator: adjacent to digits it joins their run
|
|
75
|
+
(UAX #9 W5), so '42%' survives an RTL paragraph unaided.
|
|
76
|
+
Splitting the sentence to wrap it would buy nothing and
|
|
77
|
+
break the phrase into fragments. */}
|
|
72
78
|
<span className="text-xs text-gray-500">
|
|
73
79
|
{stagePercentage}% of total
|
|
74
80
|
</span>
|
|
75
81
|
<span className="text-sm font-bold text-gray-900">
|
|
76
|
-
{stage.count.toLocaleString()}
|
|
82
|
+
<Numeral>{stage.count.toLocaleString()}</Numeral>
|
|
77
83
|
</span>
|
|
78
84
|
</div>
|
|
79
85
|
</div>
|
|
@@ -113,7 +119,9 @@ export function PipelineFunnel({
|
|
|
113
119
|
<div className="flex items-center justify-between text-sm">
|
|
114
120
|
<span className="text-gray-600">Overall Conversion</span>
|
|
115
121
|
<span className="font-bold text-gray-900">
|
|
116
|
-
|
|
122
|
+
<Numeral>
|
|
123
|
+
{Math.round((stages[stages.length - 1].count / stages[0].count) * 100)}%
|
|
124
|
+
</Numeral>
|
|
117
125
|
<span className="text-xs text-gray-500 ml-1">
|
|
118
126
|
({stages[0].stage} → {stages[stages.length - 1].stage})
|
|
119
127
|
</span>
|
|
@@ -4,6 +4,7 @@ import { Caveat } from '../states/Caveat'
|
|
|
4
4
|
import { Footnote } from '../states/Footnote'
|
|
5
5
|
import { AreaTrend } from './AreaTrend'
|
|
6
6
|
import { DeltaPill, type DeltaPillProps } from './DeltaPill'
|
|
7
|
+
import { Numeral } from './Numeral'
|
|
7
8
|
|
|
8
9
|
export type StatTileSize = 'sm' | 'md' | 'lg' | 'xl'
|
|
9
10
|
|
|
@@ -81,13 +82,17 @@ export function StatTile({
|
|
|
81
82
|
</p>
|
|
82
83
|
|
|
83
84
|
<div className="mt-2 flex items-center justify-between gap-4">
|
|
85
|
+
{/* The figure is pinned, the paragraph is not. Putting `dir` on the
|
|
86
|
+
block would left-align the number in an RTL column; the number has
|
|
87
|
+
to sit at the column's start like every other tile, and only its
|
|
88
|
+
own characters need holding in place. */}
|
|
84
89
|
<p
|
|
85
90
|
className={cn(
|
|
86
91
|
'min-w-0 font-semibold tabular-nums tracking-tight text-foreground',
|
|
87
92
|
VALUE_SIZE[size],
|
|
88
93
|
)}
|
|
89
94
|
>
|
|
90
|
-
{value}
|
|
95
|
+
<Numeral>{value}</Numeral>
|
|
91
96
|
</p>
|
|
92
97
|
|
|
93
98
|
{hasSparkline && (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Card } from '../ui/card';
|
|
2
2
|
import { EmptyState } from '../states';
|
|
3
3
|
import { ArrowRight } from 'lucide-react';
|
|
4
|
+
import { Numeral } from './Numeral';
|
|
4
5
|
|
|
5
6
|
export interface TopCampaign {
|
|
6
7
|
id: string;
|
|
@@ -95,7 +96,7 @@ export function TopCampaigns({
|
|
|
95
96
|
</div>
|
|
96
97
|
|
|
97
98
|
<span className="flex-shrink-0 text-sm font-bold text-gray-900">
|
|
98
|
-
{campaign.engagementRate}
|
|
99
|
+
<Numeral>{campaign.engagementRate}%</Numeral>
|
|
99
100
|
</span>
|
|
100
101
|
</div>
|
|
101
102
|
|
|
@@ -120,9 +121,11 @@ export function TopCampaigns({
|
|
|
120
121
|
<div className="flex items-center justify-between text-sm">
|
|
121
122
|
<span className="text-gray-600">Average Engagement</span>
|
|
122
123
|
<span className="font-bold text-gray-900">
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
<Numeral>
|
|
125
|
+
{Math.round(
|
|
126
|
+
campaigns.reduce((sum, c) => sum + c.engagementRate, 0) / campaigns.length
|
|
127
|
+
)}%
|
|
128
|
+
</Numeral>
|
|
126
129
|
</span>
|
|
127
130
|
</div>
|
|
128
131
|
</div>
|
|
@@ -161,7 +161,10 @@ describe('StatTile', () => {
|
|
|
161
161
|
expect(container.firstChild).toHaveClass('bg-card')
|
|
162
162
|
expect(container.firstChild).toHaveClass('border-border')
|
|
163
163
|
expect(screen.getByText('A')).toHaveClass('text-muted-foreground')
|
|
164
|
-
|
|
164
|
+
// The figure sits in a `Numeral` that pins it left-to-right, so the element
|
|
165
|
+
// holding the text is that span; the colour token stays on the paragraph
|
|
166
|
+
// around it and is inherited.
|
|
167
|
+
expect(screen.getByText('1').parentElement).toHaveClass('text-foreground')
|
|
165
168
|
})
|
|
166
169
|
})
|
|
167
170
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric primitives carry their own direction (startsim-768w.35.6).
|
|
3
|
+
*
|
|
4
|
+
* jsdom does not run the Unicode bidi algorithm, so no unit test can prove that
|
|
5
|
+
* `+2.8K (+0.03%)` renders correctly in an RTL browser — that claim is settled
|
|
6
|
+
* by driving a real page. What these tests DO pin down is the contract a
|
|
7
|
+
* browser needs in order to get it right: the figure, and only the figure, is
|
|
8
|
+
* marked `dir="ltr"`, in every primitive that composes one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { render, screen } from '@testing-library/react'
|
|
12
|
+
import { DeltaPill } from '../DeltaPill'
|
|
13
|
+
import { DonutChart } from '../DonutChart'
|
|
14
|
+
import { MetricCard } from '../MetricCard'
|
|
15
|
+
import { Numeral } from '../Numeral'
|
|
16
|
+
import { StatTile } from '../StatTile'
|
|
17
|
+
|
|
18
|
+
/** Renders inside an RTL document, the way a localized app would. */
|
|
19
|
+
function renderRtl(ui: React.ReactElement) {
|
|
20
|
+
return render(ui, {
|
|
21
|
+
container: document.body.appendChild(
|
|
22
|
+
Object.assign(document.createElement('div'), { dir: 'rtl' }),
|
|
23
|
+
),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Icon = ({ className }: { className?: string }) => (
|
|
28
|
+
<svg className={className} />
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
describe('Numeral', () => {
|
|
32
|
+
it('pins its contents left-to-right', () => {
|
|
33
|
+
render(<Numeral>1,204</Numeral>)
|
|
34
|
+
expect(screen.getByText('1,204')).toHaveAttribute('dir', 'ltr')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('cannot be talked out of it', () => {
|
|
38
|
+
// A caller spreading props must not be able to undo the one guarantee.
|
|
39
|
+
render(<Numeral {...({ dir: 'rtl' } as object)}>1,204</Numeral>)
|
|
40
|
+
expect(screen.getByText('1,204')).toHaveAttribute('dir', 'ltr')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('keeps figures tabular by default and takes extra classes', () => {
|
|
44
|
+
render(<Numeral className="text-foreground">99</Numeral>)
|
|
45
|
+
const figure = screen.getByText('99')
|
|
46
|
+
expect(figure).toHaveClass('tabular-nums')
|
|
47
|
+
expect(figure).toHaveClass('text-foreground')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('DeltaPill pins the figure it signs', () => {
|
|
52
|
+
it('marks the signed value, in an RTL document', () => {
|
|
53
|
+
renderRtl(<DeltaPill value={2.8} />)
|
|
54
|
+
// The leading '+' is the hazard: bidi-neutral, so unpinned it migrates.
|
|
55
|
+
expect(screen.getByText('+2.8%')).toHaveAttribute('dir', 'ltr')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('leaves the trailing label to follow the document', () => {
|
|
59
|
+
renderRtl(<DeltaPill value={-1.2} label="vs last month" />)
|
|
60
|
+
expect(screen.getByText('-1.2%')).toHaveAttribute('dir', 'ltr')
|
|
61
|
+
// Prose is prose — it should mirror with everything else on the page.
|
|
62
|
+
expect(screen.getByText('vs last month')).not.toHaveAttribute('dir')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('renders identically in an LTR document', () => {
|
|
66
|
+
const { container: ltr } = render(<DeltaPill value={2.8} />)
|
|
67
|
+
const { container: rtl } = renderRtl(<DeltaPill value={2.8} />)
|
|
68
|
+
expect(ltr.textContent).toBe(rtl.textContent)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('StatTile pins its headline figure', () => {
|
|
73
|
+
it('marks the value without pinning the block around it', () => {
|
|
74
|
+
renderRtl(<StatTile label="Monthly listeners" value="1.1M" />)
|
|
75
|
+
const figure = screen.getByText('1.1M')
|
|
76
|
+
expect(figure).toHaveAttribute('dir', 'ltr')
|
|
77
|
+
// The paragraph must stay unpinned or the number would left-align in an
|
|
78
|
+
// RTL column instead of sitting at the column's start.
|
|
79
|
+
expect(figure.parentElement).not.toHaveAttribute('dir')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('pins a currency figure the caller pre-formatted', () => {
|
|
83
|
+
renderRtl(<StatTile label="Revenue" value="$12,340" />)
|
|
84
|
+
expect(screen.getByText('$12,340')).toHaveAttribute('dir', 'ltr')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('pins the delta it renders for you', () => {
|
|
88
|
+
renderRtl(
|
|
89
|
+
<StatTile label="Streams" value="2.4M" delta={{ value: 0.03 }} />,
|
|
90
|
+
)
|
|
91
|
+
expect(screen.getByText('+0.03%')).toHaveAttribute('dir', 'ltr')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('MetricCard pins value and trend', () => {
|
|
96
|
+
it('marks both figures', () => {
|
|
97
|
+
renderRtl(
|
|
98
|
+
<MetricCard
|
|
99
|
+
label="Followers"
|
|
100
|
+
value="18,402"
|
|
101
|
+
icon={Icon}
|
|
102
|
+
trend={{ value: 12, isPositive: true }}
|
|
103
|
+
/>,
|
|
104
|
+
)
|
|
105
|
+
expect(screen.getByText('18,402')).toHaveAttribute('dir', 'ltr')
|
|
106
|
+
expect(screen.getByText('+12%')).toHaveAttribute('dir', 'ltr')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not pin the error placeholder, which is prose', () => {
|
|
110
|
+
renderRtl(<MetricCard label="Followers" value="0" icon={Icon} error />)
|
|
111
|
+
expect(screen.getByText('Error')).not.toHaveAttribute('dir')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('DonutChart pins the figures it prints', () => {
|
|
116
|
+
it('marks the centre value and the legend figures', () => {
|
|
117
|
+
renderRtl(
|
|
118
|
+
<DonutChart
|
|
119
|
+
segments={[
|
|
120
|
+
{ label: 'Spotify', value: 60 },
|
|
121
|
+
{ label: 'Apple', value: 40 },
|
|
122
|
+
]}
|
|
123
|
+
centerValue="$1.2M"
|
|
124
|
+
showLegend
|
|
125
|
+
formatLegendValue={(s) => `$${s.value}`}
|
|
126
|
+
/>,
|
|
127
|
+
)
|
|
128
|
+
expect(screen.getByText('$1.2M')).toHaveAttribute('dir', 'ltr')
|
|
129
|
+
expect(screen.getByText('$60')).toHaveAttribute('dir', 'ltr')
|
|
130
|
+
expect(screen.getByText('$40')).toHaveAttribute('dir', 'ltr')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -25,6 +25,9 @@ export type { StatTileProps, StatTileSize } from './StatTile'
|
|
|
25
25
|
export { DeltaPill, resolveDeltaDirection, DELTA_UNAVAILABLE } from './DeltaPill'
|
|
26
26
|
export type { DeltaPillProps, DeltaDirection } from './DeltaPill'
|
|
27
27
|
|
|
28
|
+
export { Numeral } from './Numeral'
|
|
29
|
+
export type { NumeralProps } from './Numeral'
|
|
30
|
+
|
|
28
31
|
export { DonutChart, donutSlices } from './DonutChart'
|
|
29
32
|
export type { DonutChartProps, DonutSegment, DonutSlice } from './DonutChart'
|
|
30
33
|
|