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.
Files changed (138) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +70 -6
  3. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  4. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  5. package/lib/commonjs/components/Attached/Attached.js +76 -7
  6. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  7. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  8. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  11. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  12. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  13. package/lib/commonjs/components/FormField/FormField.js +1 -14
  14. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
  15. package/lib/commonjs/components/ListItem/ListItem.js +6 -11
  16. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  17. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  18. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  19. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
  20. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  21. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  22. package/lib/commonjs/components/index.js +34 -0
  23. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  24. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  25. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  26. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  27. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  28. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  29. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  30. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  31. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  32. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  33. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  34. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  35. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  36. package/lib/commonjs/icons/components/index.js +132 -0
  37. package/lib/commonjs/icons/registry.js +2 -2
  38. package/lib/module/components/AppBar/AppBar.js +70 -6
  39. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  40. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  41. package/lib/module/components/Attached/Attached.js +76 -7
  42. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  43. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  44. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  45. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  46. package/lib/module/components/Drawer/Drawer.js +6 -1
  47. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  48. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  49. package/lib/module/components/FormField/FormField.js +3 -16
  50. package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
  51. package/lib/module/components/ListItem/ListItem.js +6 -11
  52. package/lib/module/components/MessageField/MessageField.js +3 -15
  53. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  54. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  55. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
  56. package/lib/module/components/Spinner/Spinner.js +212 -0
  57. package/lib/module/components/TextInput/TextInput.js +34 -19
  58. package/lib/module/components/index.js +4 -0
  59. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  60. package/lib/module/icons/components/IconArrowdown.js +12 -0
  61. package/lib/module/icons/components/IconArrowup.js +12 -0
  62. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  63. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  64. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  65. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  66. package/lib/module/icons/components/IconOsnavback.js +12 -0
  67. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  68. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  69. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  70. package/lib/module/icons/components/IconSignin.js +12 -0
  71. package/lib/module/icons/components/IconSignout.js +12 -0
  72. package/lib/module/icons/components/index.js +12 -0
  73. package/lib/module/icons/registry.js +2 -2
  74. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  75. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  76. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  77. package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
  78. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  79. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  80. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  81. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  82. package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
  83. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  84. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  85. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
  86. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  87. package/lib/typescript/src/components/index.d.ts +4 -0
  88. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  89. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  90. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  91. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  92. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  93. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  94. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  95. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  96. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  97. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  98. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  99. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  100. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  101. package/lib/typescript/src/icons/registry.d.ts +1 -1
  102. package/package.json +3 -2
  103. package/src/components/AppBar/AppBar.tsx +92 -12
  104. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  105. package/src/components/AreaLineChart/chartMath.ts +265 -0
  106. package/src/components/Attached/Attached.tsx +94 -7
  107. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  108. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  109. package/src/components/Checkbox/Checkbox.tsx +14 -2
  110. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  111. package/src/components/Drawer/Drawer.tsx +4 -0
  112. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  113. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  114. package/src/components/FormField/FormField.tsx +3 -19
  115. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
  116. package/src/components/ListItem/ListItem.tsx +14 -16
  117. package/src/components/MessageField/MessageField.tsx +3 -18
  118. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  119. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  120. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
  121. package/src/components/Spinner/Spinner.tsx +273 -0
  122. package/src/components/TextInput/TextInput.tsx +37 -19
  123. package/src/components/index.ts +4 -0
  124. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  125. package/src/icons/components/IconArrowdown.tsx +11 -0
  126. package/src/icons/components/IconArrowup.tsx +11 -0
  127. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  128. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  129. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  130. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  131. package/src/icons/components/IconOsnavback.tsx +11 -0
  132. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  133. package/src/icons/components/IconOsnavhome.tsx +11 -0
  134. package/src/icons/components/IconOsnavtask.tsx +11 -0
  135. package/src/icons/components/IconSignin.tsx +11 -0
  136. package/src/icons/components/IconSignout.tsx +11 -0
  137. package/src/icons/components/index.ts +12 -0
  138. 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 [badgeSize, setBadgeSize] = useState<Size>(ZERO_SIZE)
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
- setBadgeSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
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 && badgeSize.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 - badgeSize.width / 2,
157
- top: anchorY - badgeSize.height / 2,
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, badgeSize])
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
- {badgeChildren}
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)