@startsimpli/ui 0.4.96 → 0.4.98

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.96",
3
+ "version": "0.4.98",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -44,8 +44,8 @@
44
44
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
45
45
  "react": "^18.0.0 || ^19.0.0",
46
46
  "react-dom": "^18.0.0 || ^19.0.0",
47
- "@startsimpli/api": "^0.5.54",
48
47
  "@startsimpli/auth": "^0.4.35",
48
+ "@startsimpli/api": "^0.5.55",
49
49
  "@startsimpli/i18n": "^0.1.2"
50
50
  },
51
51
  "peerDependenciesMeta": {
@@ -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",
@@ -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
- <span className="tabular-nums">{text}</span>
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
- <span className="text-xl font-semibold tabular-nums tracking-tight text-foreground">
162
+ <Numeral className="text-xl font-semibold tracking-tight text-foreground">
162
163
  {centerValue}
163
- </span>
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
- <span className="shrink-0 font-medium tabular-nums text-foreground">
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
- </span>
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
- {isLoading ? '\u2014' : error ? 'Error' : value}
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
- <span>
136
+ {/* Sign and '%' are both bidi-neutral — unpinned, '+12%' reorders. */}
137
+ <Numeral>
128
138
  {trend.value > 0 ? '+' : ''}{trend.value}%
129
- </span>
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
- {Math.round((stages[stages.length - 1].count / stages[0].count) * 100)}%
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
- {Math.round(
124
- campaigns.reduce((sum, c) => sum + c.engagementRate, 0) / campaigns.length
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
- expect(screen.getByText('1')).toHaveClass('text-foreground')
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