jfs-components 0.0.79 → 0.0.85
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/CHANGELOG.md +29 -0
- package/lib/commonjs/components/AppBar/AppBar.js +70 -6
- package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
- package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
- package/lib/commonjs/components/Attached/Attached.js +76 -7
- package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
- package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
- package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
- package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
- package/lib/commonjs/components/Drawer/Drawer.js +6 -1
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/commonjs/components/FormField/FormField.js +1 -14
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/commonjs/components/ListItem/ListItem.js +6 -11
- package/lib/commonjs/components/MessageField/MessageField.js +1 -13
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
- package/lib/commonjs/components/Spinner/Spinner.js +217 -0
- package/lib/commonjs/components/TextInput/TextInput.js +33 -18
- package/lib/commonjs/components/index.js +34 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
- package/lib/commonjs/icons/components/IconArrowup.js +19 -0
- package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
- package/lib/commonjs/icons/components/IconSignin.js +19 -0
- package/lib/commonjs/icons/components/IconSignout.js +19 -0
- package/lib/commonjs/icons/components/index.js +132 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AppBar/AppBar.js +70 -6
- package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
- package/lib/module/components/AreaLineChart/chartMath.js +242 -0
- package/lib/module/components/Attached/Attached.js +76 -7
- package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
- package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
- package/lib/module/components/Checkbox/Checkbox.js +18 -2
- package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
- package/lib/module/components/Drawer/Drawer.js +6 -1
- package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/module/components/FormField/FormField.js +3 -16
- package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/module/components/ListItem/ListItem.js +6 -11
- package/lib/module/components/MessageField/MessageField.js +3 -15
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
- package/lib/module/components/Spinner/Spinner.js +212 -0
- package/lib/module/components/TextInput/TextInput.js +34 -19
- package/lib/module/components/index.js +4 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/components/IconArrowdown.js +12 -0
- package/lib/module/icons/components/IconArrowup.js +12 -0
- package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
- package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
- package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
- package/lib/module/icons/components/IconChevronupcircle.js +12 -0
- package/lib/module/icons/components/IconOsnavback.js +12 -0
- package/lib/module/icons/components/IconOsnavcenter.js +12 -0
- package/lib/module/icons/components/IconOsnavhome.js +12 -0
- package/lib/module/icons/components/IconOsnavtask.js +12 -0
- package/lib/module/icons/components/IconSignin.js +12 -0
- package/lib/module/icons/components/IconSignout.js +12 -0
- package/lib/module/icons/components/index.js +12 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
- package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
- package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
- package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
- package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
- package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
- package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
- package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
- package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
- package/lib/typescript/src/components/index.d.ts +4 -0
- package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +12 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +3 -2
- package/src/components/AppBar/AppBar.tsx +92 -12
- package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
- package/src/components/AreaLineChart/chartMath.ts +265 -0
- package/src/components/Attached/Attached.tsx +94 -7
- package/src/components/BubbleChart/BubbleChart.tsx +319 -0
- package/src/components/BubbleChart/bubblePacking.ts +397 -0
- package/src/components/Checkbox/Checkbox.tsx +14 -2
- package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
- package/src/components/Drawer/Drawer.tsx +4 -0
- package/src/components/DropdownInput/DropdownInput.tsx +54 -20
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
- package/src/components/FormField/FormField.tsx +3 -19
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
- package/src/components/ListItem/ListItem.tsx +14 -16
- package/src/components/MessageField/MessageField.tsx +3 -18
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
- package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
- package/src/components/Spinner/Spinner.tsx +273 -0
- package/src/components/TextInput/TextInput.tsx +37 -19
- package/src/components/index.ts +4 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/components/IconArrowdown.tsx +11 -0
- package/src/icons/components/IconArrowup.tsx +11 -0
- package/src/icons/components/IconChevrondowncircle.tsx +11 -0
- package/src/icons/components/IconChevronleftcircle.tsx +11 -0
- package/src/icons/components/IconChevronrightcircle.tsx +11 -0
- package/src/icons/components/IconChevronupcircle.tsx +11 -0
- package/src/icons/components/IconOsnavback.tsx +11 -0
- package/src/icons/components/IconOsnavcenter.tsx +11 -0
- package/src/icons/components/IconOsnavhome.tsx +11 -0
- package/src/icons/components/IconOsnavtask.tsx +11 -0
- package/src/icons/components/IconSignin.tsx +11 -0
- package/src/icons/components/IconSignout.tsx +11 -0
- package/src/icons/components/index.ts +12 -0
- package/src/icons/registry.ts +49 -1
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, framework-agnostic math helpers for `AreaLineChart`.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is intentionally dependency-free (no d3, no react-native)
|
|
5
|
+
* so the chart can stay lightweight and be unit-reasoned in isolation. The
|
|
6
|
+
* functions cover the four primitives a line/area chart needs:
|
|
7
|
+
* 1. linear scales (data domain -> pixel range)
|
|
8
|
+
* 2. "nice" tick generation for the value axis
|
|
9
|
+
* 3. SVG path generation for lines and filled areas (straight + smooth)
|
|
10
|
+
* 4. interaction lookup (which data index is nearest a touch x)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** A resolved point in data space. `x` is the categorical/linear index. */
|
|
14
|
+
export type ResolvedPoint = {
|
|
15
|
+
/** Position along the x domain (defaults to the array index). */
|
|
16
|
+
x: number
|
|
17
|
+
/** Value along the y domain. */
|
|
18
|
+
y: number
|
|
19
|
+
/**
|
|
20
|
+
* When true, the line segment that *ends* at this point is rendered
|
|
21
|
+
* dashed (used for projected / low-confidence data).
|
|
22
|
+
*/
|
|
23
|
+
projected: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A point already projected into pixel space. */
|
|
27
|
+
export type PixelPoint = { x: number; y: number; projected: boolean }
|
|
28
|
+
|
|
29
|
+
/** Linear interpolation scale: maps a value from `domain` into `range`. */
|
|
30
|
+
export type LinearScale = {
|
|
31
|
+
(value: number): number
|
|
32
|
+
domain: readonly [number, number]
|
|
33
|
+
range: readonly [number, number]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a linear scale mapping `domain` -> `range`. Mirrors the subset of
|
|
38
|
+
* `d3-scale.scaleLinear` we rely on, without the dependency. A zero-width
|
|
39
|
+
* domain collapses to the midpoint of the range so we never divide by zero.
|
|
40
|
+
*/
|
|
41
|
+
export function createLinearScale(
|
|
42
|
+
domain: readonly [number, number],
|
|
43
|
+
range: readonly [number, number]
|
|
44
|
+
): LinearScale {
|
|
45
|
+
const [d0, d1] = domain
|
|
46
|
+
const [r0, r1] = range
|
|
47
|
+
const domainSpan = d1 - d0
|
|
48
|
+
|
|
49
|
+
const scale = ((value: number): number => {
|
|
50
|
+
if (domainSpan === 0) {
|
|
51
|
+
return (r0 + r1) / 2
|
|
52
|
+
}
|
|
53
|
+
const t = (value - d0) / domainSpan
|
|
54
|
+
return r0 + t * (r1 - r0)
|
|
55
|
+
}) as LinearScale
|
|
56
|
+
|
|
57
|
+
scale.domain = domain
|
|
58
|
+
scale.range = range
|
|
59
|
+
return scale
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Generate up to ~`count` "nice" evenly-spaced tick values spanning
|
|
64
|
+
* `[min, max]`, snapping the step to a 1/2/5 * 10^n progression so labels
|
|
65
|
+
* read cleanly (e.g. 0, 30K, 60K, 90K). Returns ascending values that
|
|
66
|
+
* always include the rounded-down min and rounded-up max bounds.
|
|
67
|
+
*/
|
|
68
|
+
export function niceTicks(min: number, max: number, count = 5): number[] {
|
|
69
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
70
|
+
return []
|
|
71
|
+
}
|
|
72
|
+
if (min === max) {
|
|
73
|
+
return [min]
|
|
74
|
+
}
|
|
75
|
+
const safeCount = Math.max(1, Math.floor(count))
|
|
76
|
+
const span = max - min
|
|
77
|
+
const rawStep = span / safeCount
|
|
78
|
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)))
|
|
79
|
+
const normalized = rawStep / magnitude
|
|
80
|
+
|
|
81
|
+
let niceNormalized: number
|
|
82
|
+
if (normalized <= 1) niceNormalized = 1
|
|
83
|
+
else if (normalized <= 2) niceNormalized = 2
|
|
84
|
+
else if (normalized <= 5) niceNormalized = 5
|
|
85
|
+
else niceNormalized = 10
|
|
86
|
+
|
|
87
|
+
const step = niceNormalized * magnitude
|
|
88
|
+
const niceMin = Math.floor(min / step) * step
|
|
89
|
+
const niceMax = Math.ceil(max / step) * step
|
|
90
|
+
|
|
91
|
+
const ticks: number[] = []
|
|
92
|
+
// Guard against floating point drift by rounding to the step's precision.
|
|
93
|
+
const decimals = Math.max(0, -Math.floor(Math.log10(step)))
|
|
94
|
+
for (let v = niceMin; v <= niceMax + step / 2; v += step) {
|
|
95
|
+
ticks.push(Number(v.toFixed(decimals)))
|
|
96
|
+
}
|
|
97
|
+
return ticks
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute the [min, max] extent of an array of resolved points along the
|
|
102
|
+
* requested axis. Returns `[0, 0]` for an empty list.
|
|
103
|
+
*/
|
|
104
|
+
export function extent(points: ResolvedPoint[], axis: 'x' | 'y'): [number, number] {
|
|
105
|
+
if (points.length === 0) {
|
|
106
|
+
return [0, 0]
|
|
107
|
+
}
|
|
108
|
+
let min = Infinity
|
|
109
|
+
let max = -Infinity
|
|
110
|
+
for (const p of points) {
|
|
111
|
+
const v = p[axis]
|
|
112
|
+
if (v < min) min = v
|
|
113
|
+
if (v > max) max = v
|
|
114
|
+
}
|
|
115
|
+
return [min, max]
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalize the loose `number[] | ChartPoint[]` series data into a uniform
|
|
120
|
+
* `ResolvedPoint[]`. Bare numbers use their array index as the x value.
|
|
121
|
+
*/
|
|
122
|
+
export function resolvePoints(
|
|
123
|
+
data: ReadonlyArray<number | { x?: number | string; y: number; projected?: boolean }>
|
|
124
|
+
): ResolvedPoint[] {
|
|
125
|
+
return data.map((entry, index) => {
|
|
126
|
+
if (typeof entry === 'number') {
|
|
127
|
+
return { x: index, y: entry, projected: false }
|
|
128
|
+
}
|
|
129
|
+
const x = typeof entry.x === 'number' ? entry.x : index
|
|
130
|
+
return { x, y: entry.y, projected: entry.projected === true }
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Catmull-Rom -> cubic Bézier control point helper. Produces a smooth,
|
|
136
|
+
* monotone-ish curve through the points without overshooting wildly. Used
|
|
137
|
+
* for the `curve="monotone"` mode. `tension` of 0 yields a standard
|
|
138
|
+
* Catmull-Rom spline.
|
|
139
|
+
*/
|
|
140
|
+
function controlPoints(
|
|
141
|
+
p0: PixelPoint,
|
|
142
|
+
p1: PixelPoint,
|
|
143
|
+
p2: PixelPoint,
|
|
144
|
+
p3: PixelPoint
|
|
145
|
+
): { cp1x: number; cp1y: number; cp2x: number; cp2y: number } {
|
|
146
|
+
const t = 1 / 6
|
|
147
|
+
return {
|
|
148
|
+
cp1x: p1.x + (p2.x - p0.x) * t,
|
|
149
|
+
cp1y: p1.y + (p2.y - p0.y) * t,
|
|
150
|
+
cp2x: p2.x - (p3.x - p1.x) * t,
|
|
151
|
+
cp2y: p2.y - (p3.y - p1.y) * t,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type Curve = 'linear' | 'monotone'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* One drawable line segment. `dashed` mirrors the `projected` flag of the
|
|
159
|
+
* point the segment ends at, letting the renderer split the polyline into
|
|
160
|
+
* solid and dashed `Path`s.
|
|
161
|
+
*/
|
|
162
|
+
export type LineSegment = { d: string; dashed: boolean }
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the SVG path(s) for a polyline through `points`, split into runs of
|
|
166
|
+
* solid vs dashed segments. Each individual segment ("move to A, line/curve
|
|
167
|
+
* to B") is emitted as its own sub-path so that a single projected point can
|
|
168
|
+
* dash exactly one segment while leaving its neighbours solid.
|
|
169
|
+
*/
|
|
170
|
+
export function buildLineSegments(points: PixelPoint[], curve: Curve): LineSegment[] {
|
|
171
|
+
if (points.length < 2) {
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const segments: LineSegment[] = []
|
|
176
|
+
|
|
177
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
178
|
+
const start = points[i]
|
|
179
|
+
const end = points[i + 1]
|
|
180
|
+
const dashed = end.projected
|
|
181
|
+
|
|
182
|
+
let d: string
|
|
183
|
+
if (curve === 'monotone') {
|
|
184
|
+
const p0 = points[i - 1] ?? start
|
|
185
|
+
const p3 = points[i + 2] ?? end
|
|
186
|
+
const { cp1x, cp1y, cp2x, cp2y } = controlPoints(p0, start, end, p3)
|
|
187
|
+
d = `M ${start.x} ${start.y} C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
|
|
188
|
+
} else {
|
|
189
|
+
d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`
|
|
190
|
+
}
|
|
191
|
+
segments.push({ d, dashed })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Merge adjacent segments that share the same dashed-ness into one path
|
|
195
|
+
// string to minimize the number of rendered <Path> nodes.
|
|
196
|
+
const merged: LineSegment[] = []
|
|
197
|
+
for (const seg of segments) {
|
|
198
|
+
const last = merged[merged.length - 1]
|
|
199
|
+
if (last && last.dashed === seg.dashed) {
|
|
200
|
+
// Drop the redundant leading "M x y" of the appended segment.
|
|
201
|
+
const continuation = seg.d.replace(/^M [^A-Za-z]+/, '')
|
|
202
|
+
last.d += ' ' + continuation
|
|
203
|
+
} else {
|
|
204
|
+
merged.push({ ...seg })
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return merged
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a single closed area path (the line, then down to `baselineY` and
|
|
212
|
+
* back) for a filled area fill. Curve smoothing matches `buildLineSegments`.
|
|
213
|
+
*/
|
|
214
|
+
export function buildAreaPath(
|
|
215
|
+
points: PixelPoint[],
|
|
216
|
+
baselineY: number,
|
|
217
|
+
curve: Curve
|
|
218
|
+
): string {
|
|
219
|
+
if (points.length === 0) {
|
|
220
|
+
return ''
|
|
221
|
+
}
|
|
222
|
+
if (points.length === 1) {
|
|
223
|
+
const p = points[0]
|
|
224
|
+
return `M ${p.x} ${baselineY} L ${p.x} ${p.y} Z`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let d = `M ${points[0].x} ${baselineY} L ${points[0].x} ${points[0].y}`
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
230
|
+
const start = points[i]
|
|
231
|
+
const end = points[i + 1]
|
|
232
|
+
if (curve === 'monotone') {
|
|
233
|
+
const p0 = points[i - 1] ?? start
|
|
234
|
+
const p3 = points[i + 2] ?? end
|
|
235
|
+
const { cp1x, cp1y, cp2x, cp2y } = controlPoints(p0, start, end, p3)
|
|
236
|
+
d += ` C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${end.x} ${end.y}`
|
|
237
|
+
} else {
|
|
238
|
+
d += ` L ${end.x} ${end.y}`
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const lastX = points[points.length - 1].x
|
|
243
|
+
d += ` L ${lastX} ${baselineY} Z`
|
|
244
|
+
return d
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Find the index of the point whose pixel-x is nearest to `targetX`. Used to
|
|
249
|
+
* snap the crosshair / tooltip to the closest data point during hover/drag.
|
|
250
|
+
*/
|
|
251
|
+
export function nearestIndex(pixelXs: number[], targetX: number): number {
|
|
252
|
+
if (pixelXs.length === 0) {
|
|
253
|
+
return -1
|
|
254
|
+
}
|
|
255
|
+
let best = 0
|
|
256
|
+
let bestDist = Infinity
|
|
257
|
+
for (let i = 0; i < pixelXs.length; i++) {
|
|
258
|
+
const dist = Math.abs(pixelXs[i] - targetX)
|
|
259
|
+
if (dist < bestDist) {
|
|
260
|
+
bestDist = dist
|
|
261
|
+
best = i
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return best
|
|
265
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ViewStyle,
|
|
8
8
|
} from 'react-native'
|
|
9
9
|
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
10
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
10
11
|
import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -37,6 +38,24 @@ export type AttachedProps = Omit<ViewProps, 'children'> & {
|
|
|
37
38
|
* `modes` are cascaded into it as well.
|
|
38
39
|
*/
|
|
39
40
|
badge?: React.ReactNode
|
|
41
|
+
/**
|
|
42
|
+
* Enforces a fixed square size (in px) on the `badge` slot, regardless of
|
|
43
|
+
* what node is passed. The badge is wrapped in a box of
|
|
44
|
+
* `badgeSize × badgeSize` with `overflow: 'hidden'`, and the badge content is
|
|
45
|
+
* stretched to fill it. Use this to guarantee the design-token size even when
|
|
46
|
+
* a consumer drops in an arbitrary node (e.g. an `Image`) whose intrinsic
|
|
47
|
+
* size/aspect-ratio would otherwise win.
|
|
48
|
+
*
|
|
49
|
+
* When omitted, the badge keeps its own intrinsic size (legacy behavior).
|
|
50
|
+
*/
|
|
51
|
+
badgeSize?: number
|
|
52
|
+
/**
|
|
53
|
+
* Corner radius used to clip the `badge` box. Only applies when `badgeSize`
|
|
54
|
+
* is set. Anything that overflows the rounded box (e.g. a non-square image)
|
|
55
|
+
* is clipped.
|
|
56
|
+
* @default badgeSize / 2 (a full circle)
|
|
57
|
+
*/
|
|
58
|
+
badgeRadius?: number
|
|
40
59
|
/**
|
|
41
60
|
* Anchor point for the `badge` relative to the main content.
|
|
42
61
|
* @default 'bottom-right'
|
|
@@ -89,9 +108,28 @@ function resolveAnchorFractions(position: AttachedPosition): { fx: number; fy: n
|
|
|
89
108
|
* </Attached>
|
|
90
109
|
* ```
|
|
91
110
|
*/
|
|
111
|
+
/**
|
|
112
|
+
* Stretches the immediate badge child/children to fill the enforced badge box.
|
|
113
|
+
* Merges `{ width: '100%', height: '100%' }` into each top-level element's
|
|
114
|
+
* `style` so an arbitrary node (e.g. an `Image` with its own width/aspectRatio)
|
|
115
|
+
* fills the fixed `badgeSize` box instead of laying out at its intrinsic size.
|
|
116
|
+
* The wrapping box's `overflow: 'hidden'` clips anything that still overflows.
|
|
117
|
+
*/
|
|
118
|
+
function forceBadgeFill(children: React.ReactNode): React.ReactNode {
|
|
119
|
+
return React.Children.map(children, (child) => {
|
|
120
|
+
if (!React.isValidElement(child)) return child
|
|
121
|
+
const childStyle = (child.props as any)?.style
|
|
122
|
+
return React.cloneElement(child as React.ReactElement<any>, {
|
|
123
|
+
style: [FILL_STYLE, childStyle],
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
92
128
|
function Attached({
|
|
93
129
|
children,
|
|
94
130
|
badge,
|
|
131
|
+
badgeSize,
|
|
132
|
+
badgeRadius,
|
|
95
133
|
position = 'bottom-right',
|
|
96
134
|
circular = true,
|
|
97
135
|
modes: propModes = EMPTY_MODES,
|
|
@@ -108,7 +146,7 @@ function Attached({
|
|
|
108
146
|
)
|
|
109
147
|
|
|
110
148
|
const [mainSize, setMainSize] = useState<Size>(ZERO_SIZE)
|
|
111
|
-
const [
|
|
149
|
+
const [measuredBadgeSize, setMeasuredBadgeSize] = useState<Size>(ZERO_SIZE)
|
|
112
150
|
|
|
113
151
|
const onMainLayout = useCallback((e: LayoutChangeEvent) => {
|
|
114
152
|
const { width, height } = e.nativeEvent.layout
|
|
@@ -117,7 +155,7 @@ function Attached({
|
|
|
117
155
|
|
|
118
156
|
const onBadgeLayout = useCallback((e: LayoutChangeEvent) => {
|
|
119
157
|
const { width, height } = e.nativeEvent.layout
|
|
120
|
-
|
|
158
|
+
setMeasuredBadgeSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
|
|
121
159
|
}, [])
|
|
122
160
|
|
|
123
161
|
const mainChildren = useMemo(
|
|
@@ -129,9 +167,47 @@ function Attached({
|
|
|
129
167
|
[badge, modes]
|
|
130
168
|
)
|
|
131
169
|
|
|
170
|
+
// When a fixed size is requested, the badge is wrapped in a clipped box and
|
|
171
|
+
// its content is force-stretched to fill it (see `forceBadgeFill`).
|
|
172
|
+
const badgeBoxStyle = useMemo<ViewStyle | null>(() => {
|
|
173
|
+
if (badgeSize == null) return null
|
|
174
|
+
return {
|
|
175
|
+
width: badgeSize,
|
|
176
|
+
height: badgeSize,
|
|
177
|
+
borderRadius: badgeRadius ?? badgeSize / 2,
|
|
178
|
+
overflow: 'hidden',
|
|
179
|
+
}
|
|
180
|
+
}, [badgeSize, badgeRadius])
|
|
181
|
+
|
|
182
|
+
// Forced slot ring. Like the corner radius, the border color/width are driven
|
|
183
|
+
// by design tokens (`attached/slot/border`, `attached/slot/borderWidth`) and
|
|
184
|
+
// applied to the slot regardless of the node passed. Rendered as an *outside*
|
|
185
|
+
// border: the ring lives on a wrapper that is pulled out by `-borderWidth` on
|
|
186
|
+
// every side (negative margin) so it grows outward instead of eating into the
|
|
187
|
+
// content, and its layout footprint stays equal to the slot box (keeping the
|
|
188
|
+
// anchor centering intact).
|
|
189
|
+
const slotBorderStyle = useMemo<ViewStyle | null>(() => {
|
|
190
|
+
const borderColor = getVariableByName('attached/slot/border', modes)
|
|
191
|
+
const borderWidth = parseFloat(getVariableByName('attached/slot/borderWidth', modes) || '0')
|
|
192
|
+
if (!borderColor || !(borderWidth > 0)) return null
|
|
193
|
+
|
|
194
|
+
// Match the inner clip radius so the ring stays concentric. The outer edge
|
|
195
|
+
// sits `borderWidth` further out, hence `innerRadius + borderWidth`.
|
|
196
|
+
const innerRadius =
|
|
197
|
+
badgeBoxStyle != null
|
|
198
|
+
? (badgeBoxStyle.borderRadius as number)
|
|
199
|
+
: badgeRadius ?? 9999
|
|
200
|
+
return {
|
|
201
|
+
borderWidth,
|
|
202
|
+
borderColor,
|
|
203
|
+
borderRadius: (typeof innerRadius === 'number' ? innerRadius : 9999) + borderWidth,
|
|
204
|
+
margin: -borderWidth,
|
|
205
|
+
}
|
|
206
|
+
}, [modes, badgeBoxStyle, badgeRadius])
|
|
207
|
+
|
|
132
208
|
const badgePlacement = useMemo<ViewStyle>(() => {
|
|
133
209
|
const { fx, fy } = resolveAnchorFractions(position)
|
|
134
|
-
const measured = mainSize.width > 0 &&
|
|
210
|
+
const measured = mainSize.width > 0 && measuredBadgeSize.width > 0
|
|
135
211
|
|
|
136
212
|
let anchorX: number
|
|
137
213
|
let anchorY: number
|
|
@@ -153,19 +229,27 @@ function Attached({
|
|
|
153
229
|
|
|
154
230
|
return {
|
|
155
231
|
position: 'absolute',
|
|
156
|
-
left: anchorX -
|
|
157
|
-
top: anchorY -
|
|
232
|
+
left: anchorX - measuredBadgeSize.width / 2,
|
|
233
|
+
top: anchorY - measuredBadgeSize.height / 2,
|
|
158
234
|
// Hide until both elements are measured to avoid a one-frame flash at (0,0).
|
|
159
235
|
opacity: measured ? 1 : 0,
|
|
160
236
|
}
|
|
161
|
-
}, [position, circular, mainSize,
|
|
237
|
+
}, [position, circular, mainSize, measuredBadgeSize])
|
|
162
238
|
|
|
163
239
|
return (
|
|
164
240
|
<View style={[styles.container, style]} {...rest}>
|
|
165
241
|
<View onLayout={onMainLayout}>{mainChildren}</View>
|
|
166
242
|
{badgeChildren != null && (
|
|
167
243
|
<View style={badgePlacement} onLayout={onBadgeLayout} pointerEvents="box-none">
|
|
168
|
-
{
|
|
244
|
+
{(() => {
|
|
245
|
+
const slot =
|
|
246
|
+
badgeBoxStyle != null ? (
|
|
247
|
+
<View style={badgeBoxStyle}>{forceBadgeFill(badgeChildren)}</View>
|
|
248
|
+
) : (
|
|
249
|
+
badgeChildren
|
|
250
|
+
)
|
|
251
|
+
return slotBorderStyle != null ? <View style={slotBorderStyle}>{slot}</View> : slot
|
|
252
|
+
})()}
|
|
169
253
|
</View>
|
|
170
254
|
)}
|
|
171
255
|
</View>
|
|
@@ -178,4 +262,7 @@ const styles = {
|
|
|
178
262
|
container: { position: 'relative', alignSelf: 'flex-start' } as ViewStyle,
|
|
179
263
|
}
|
|
180
264
|
|
|
265
|
+
/** Fill style merged into badge content when `badgeSize` enforces a fixed box. */
|
|
266
|
+
const FILL_STYLE = { width: '100%', height: '100%' } as ViewStyle
|
|
267
|
+
|
|
181
268
|
export default React.memo(Attached)
|