@oxyhq/bloom 1.13.0 → 1.14.0
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/README.md +1 -1
- package/docs/avatar.mdx +46 -5
- package/docs/index.mdx +1 -1
- package/docs/tab-bar.mdx +35 -0
- package/lib/commonjs/avatar/Avatar.js +24 -8
- package/lib/commonjs/avatar/Avatar.js.map +1 -1
- package/lib/commonjs/avatar/AvatarRing.js +13 -12
- package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
- package/lib/commonjs/avatar/index.js +19 -0
- package/lib/commonjs/avatar/index.js.map +1 -1
- package/lib/commonjs/avatar/resolve-shape.js +43 -0
- package/lib/commonjs/avatar/resolve-shape.js.map +1 -0
- package/lib/commonjs/avatar/shape-paths.js +174 -0
- package/lib/commonjs/avatar/shape-paths.js.map +1 -0
- package/lib/commonjs/tab-bar/TabBarBase.js +53 -7
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js +5 -0
- package/lib/commonjs/tab-bar/expo-router/RouterTabBar.js.map +1 -1
- package/lib/module/avatar/Avatar.js +24 -8
- package/lib/module/avatar/Avatar.js.map +1 -1
- package/lib/module/avatar/AvatarRing.js +13 -12
- package/lib/module/avatar/AvatarRing.js.map +1 -1
- package/lib/module/avatar/index.js +1 -0
- package/lib/module/avatar/index.js.map +1 -1
- package/lib/module/avatar/resolve-shape.js +40 -0
- package/lib/module/avatar/resolve-shape.js.map +1 -0
- package/lib/module/avatar/shape-paths.js +170 -0
- package/lib/module/avatar/shape-paths.js.map +1 -0
- package/lib/module/tab-bar/TabBarBase.js +54 -8
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/expo-router/RouterTabBar.js +5 -0
- package/lib/module/tab-bar/expo-router/RouterTabBar.js.map +1 -1
- package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts +4 -3
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/index.d.ts +3 -1
- package/lib/typescript/commonjs/avatar/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/resolve-shape.d.ts +17 -0
- package/lib/typescript/commonjs/avatar/resolve-shape.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar/shape-paths.d.ts +27 -0
- package/lib/typescript/commonjs/avatar/shape-paths.d.ts.map +1 -0
- package/lib/typescript/commonjs/avatar/types.d.ts +30 -3
- package/lib/typescript/commonjs/avatar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
- package/lib/typescript/commonjs/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +36 -5
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/module/avatar/AvatarRing.d.ts +4 -3
- package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/module/avatar/index.d.ts +3 -1
- package/lib/typescript/module/avatar/index.d.ts.map +1 -1
- package/lib/typescript/module/avatar/resolve-shape.d.ts +17 -0
- package/lib/typescript/module/avatar/resolve-shape.d.ts.map +1 -0
- package/lib/typescript/module/avatar/shape-paths.d.ts +27 -0
- package/lib/typescript/module/avatar/shape-paths.d.ts.map +1 -0
- package/lib/typescript/module/avatar/types.d.ts +30 -3
- package/lib/typescript/module/avatar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts +5 -0
- package/lib/typescript/module/tab-bar/expo-router/RouterTabBar.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +36 -5
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/avatar/Avatar.tsx +27 -10
- package/src/avatar/AvatarRing.tsx +21 -14
- package/src/avatar/index.ts +7 -0
- package/src/avatar/resolve-shape.ts +33 -0
- package/src/avatar/shape-paths.ts +150 -0
- package/src/avatar/types.ts +32 -3
- package/src/tab-bar/TabBarBase.tsx +69 -10
- package/src/tab-bar/expo-router/RouterTabBar.tsx +5 -0
- package/src/tab-bar/types.ts +36 -5
- package/src/__tests__/support/collision-fixture-barrel.ts +0 -20
- package/src/__tests__/support/constructed-style-sheets.ts +0 -68
- package/src/__tests__/support/press-host.ts +0 -30
- package/src/__tests__/support/rendered-style.ts +0 -99
- package/src/__tests__/support/unread-hook-fixture.ts +0 -33
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +0 -7682
- package/src/theme/__tests__/fixtures/color-engine-golden.json +0 -1
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SQUIRCLE_PATH } from './squircle-path';
|
|
2
|
+
import { AVATAR_SHAPE_PATHS, NAMED_SHAPE_VIEW_BOX } from './shape-paths';
|
|
3
|
+
import type { AvatarShape, AvatarShapePath } from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A shape reduced to what the renderers need: either nothing (a circle, drawn
|
|
7
|
+
* with `borderRadius` and no SVG at all) or one outline plus the size of the
|
|
8
|
+
* square space it is drawn in.
|
|
9
|
+
*/
|
|
10
|
+
export type ResolvedAvatarShape = AvatarShapePath | null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Turns the public `shape` prop into an outline.
|
|
14
|
+
*
|
|
15
|
+
* Returns `null` for a circle so callers can keep the cheap `borderRadius`
|
|
16
|
+
* path — the common case never touches `react-native-svg`. An unknown string
|
|
17
|
+
* also returns `null`: a shape name that does not resolve should degrade to a
|
|
18
|
+
* plain circular avatar, never to a blank hole where a face should be.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveAvatarShape(shape: AvatarShape | AvatarShapePath | undefined): ResolvedAvatarShape {
|
|
21
|
+
if (!shape || shape === 'circle') return null;
|
|
22
|
+
|
|
23
|
+
if (typeof shape !== 'string') {
|
|
24
|
+
return { d: shape.d, viewBox: shape.viewBox ?? 1 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (shape === 'squircle') {
|
|
28
|
+
return { d: SQUIRCLE_PATH, viewBox: 1 };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const named = AVATAR_SHAPE_PATHS[shape];
|
|
32
|
+
return named ? { d: named, viewBox: NAMED_SHAPE_VIEW_BOX } : null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named avatar outlines.
|
|
3
|
+
*
|
|
4
|
+
* Each entry is an SVG path drawn in a 100×100 square coordinate space, scaled
|
|
5
|
+
* at render time to the avatar's pixel size. That space is declared once in
|
|
6
|
+
* {@link NAMED_SHAPE_VIEW_BOX} rather than baked into the numbers, so the paths
|
|
7
|
+
* can be read and edited at human-friendly magnitudes. The built-in squircle
|
|
8
|
+
* lives in `squircle-path.ts` and uses a 0–1 space; both are supported because
|
|
9
|
+
* {@link AvatarShapePath} carries its own `viewBox`.
|
|
10
|
+
*
|
|
11
|
+
* `circle` is deliberately absent: a circular avatar is rendered with
|
|
12
|
+
* `borderRadius` and never reaches the SVG renderer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Side length of the coordinate space every path below is drawn in. */
|
|
16
|
+
export const NAMED_SHAPE_VIEW_BOX = 100;
|
|
17
|
+
|
|
18
|
+
export type AvatarShapeName =
|
|
19
|
+
| 'square' | 'slanted' | 'arch' | 'semicircle'
|
|
20
|
+
| 'oval' | 'pill' | 'triangle' | 'arrow' | 'fan'
|
|
21
|
+
| 'diamond' | 'clamshell' | 'pentagon' | 'gem' | 'very-sunny'
|
|
22
|
+
| 'sunny' | '4-sided-cookie' | '6-sided-cookie' | '7-sided-cookie'
|
|
23
|
+
| '9-sided-cookie' | '12-sided-cookie' | '4-leaf-clover'
|
|
24
|
+
| '8-leaf-clover' | 'burst' | 'soft-burst' | 'boom' | 'soft-boom'
|
|
25
|
+
| 'flower' | 'puffy' | 'puffy-diamond' | 'ghost-ish'
|
|
26
|
+
| 'pixel-circle' | 'pixel-triangle' | 'bun' | 'heart';
|
|
27
|
+
|
|
28
|
+
export const AVATAR_SHAPE_PATHS: Record<AvatarShapeName, string> = {
|
|
29
|
+
// --- Basic ---
|
|
30
|
+
square:
|
|
31
|
+
'M12 0H88Q100 0 100 12V88Q100 100 88 100H12Q0 100 0 88V12Q0 0 12 0Z',
|
|
32
|
+
slanted:
|
|
33
|
+
'M22 0H88Q100 0 100 12V78Q100 100 78 100H12Q0 100 0 88V22Q0 0 22 0Z',
|
|
34
|
+
arch:
|
|
35
|
+
'M50 0Q100 0 100 50V88Q100 100 88 100H12Q0 100 0 88V50Q0 0 50 0Z',
|
|
36
|
+
semicircle:
|
|
37
|
+
'M0 50A50 50 0 0 1 100 50V88Q100 100 88 100H12Q0 100 0 88Z',
|
|
38
|
+
|
|
39
|
+
// --- Organic ---
|
|
40
|
+
oval:
|
|
41
|
+
'M50 2C78 2 96 22 96 50S78 98 50 98S4 78 4 50S22 2 50 2Z',
|
|
42
|
+
pill:
|
|
43
|
+
'M34 4H66Q96 4 96 34V66Q96 96 66 96H34Q4 96 4 66V34Q4 4 34 4Z',
|
|
44
|
+
triangle:
|
|
45
|
+
'M50 5L95 90Q97 95 92 95H8Q3 95 5 90Z',
|
|
46
|
+
arrow:
|
|
47
|
+
'M50 0L100 45Q100 52 94 52H68V92Q68 100 58 100H42Q32 100 32 92V52H6Q0 52 0 45Z',
|
|
48
|
+
fan:
|
|
49
|
+
'M50 8Q90 8 96 48Q100 78 70 96Q50 108 30 96Q0 78 4 48Q10 8 50 8Z',
|
|
50
|
+
|
|
51
|
+
// --- Geometric ---
|
|
52
|
+
diamond:
|
|
53
|
+
'M50 2L96 50L50 98L4 50Z',
|
|
54
|
+
clamshell:
|
|
55
|
+
'M10 20Q10 4 26 4H74Q90 4 90 20V60Q90 96 50 96Q10 96 10 60Z',
|
|
56
|
+
pentagon:
|
|
57
|
+
'M50 2L97 36L79 96H21L3 36Z',
|
|
58
|
+
gem:
|
|
59
|
+
'M30 4H70L98 36L50 98L2 36Z',
|
|
60
|
+
|
|
61
|
+
// --- Sunny/Cookie ---
|
|
62
|
+
'very-sunny':
|
|
63
|
+
'M50 0L58 20L78 6L72 28L96 22L82 42L100 50L82 58L96 78L72 72L78 94L58 80L50 100L42 80L22 94L28 72L4 78L18 58L0 50L18 42L4 22L28 28L22 6L42 20Z',
|
|
64
|
+
sunny:
|
|
65
|
+
'M50 4L60 24L80 10L74 32L98 28L84 48L100 50L84 52L98 72L74 68L80 90L60 76L50 96L40 76L20 90L26 68L2 72L16 52L0 50L16 48L2 28L26 32L20 10L40 24Z',
|
|
66
|
+
'4-sided-cookie':
|
|
67
|
+
'M50 2Q76 22 76 50Q76 78 50 98Q24 78 24 50Q24 22 50 2Z',
|
|
68
|
+
'6-sided-cookie':
|
|
69
|
+
'M50 2Q72 10 82 30Q92 52 80 74Q66 96 44 96Q22 96 10 74Q-2 52 10 30Q22 10 50 2Z',
|
|
70
|
+
'7-sided-cookie':
|
|
71
|
+
'M50 2Q70 6 84 22Q96 40 94 62Q90 82 74 94Q56 102 38 96Q20 88 10 70Q2 50 8 32Q18 10 50 2Z',
|
|
72
|
+
'9-sided-cookie':
|
|
73
|
+
'M50 2Q64 4 76 14Q86 26 92 42Q94 58 88 72Q78 86 64 94Q50 98 36 94Q22 86 12 72Q6 58 8 42Q14 26 24 14Q36 4 50 2Z',
|
|
74
|
+
'12-sided-cookie':
|
|
75
|
+
'M50 2L62 6L74 14L84 26L90 40L92 54L88 68L80 80L68 88L54 92L40 90L28 84L18 72L10 58L8 44L12 30L20 18L32 10L44 4Z',
|
|
76
|
+
|
|
77
|
+
// --- Nature ---
|
|
78
|
+
'4-leaf-clover':
|
|
79
|
+
'M50 4Q70 4 70 26Q96 26 96 50Q96 74 70 74Q70 96 50 96Q30 96 30 74Q4 74 4 50Q4 26 30 26Q30 4 50 4Z',
|
|
80
|
+
'8-leaf-clover':
|
|
81
|
+
'M50 2Q60 16 72 8Q84 16 80 30Q96 32 92 46Q100 60 86 66Q92 80 78 84Q76 98 60 92Q52 100 42 92Q28 98 24 84Q10 80 16 66Q2 60 10 46Q6 32 22 30Q18 16 30 8Q42 16 50 2Z',
|
|
82
|
+
burst:
|
|
83
|
+
'M50 0L56 30L80 6L62 34L100 26L70 44L100 50L70 56L100 74L62 66L80 94L56 70L50 100L44 70L20 94L38 66L0 74L30 56L0 50L30 44L0 26L38 34L20 6L44 30Z',
|
|
84
|
+
'soft-burst':
|
|
85
|
+
'M50 4L58 28L78 8L66 32L96 24L76 44L98 50L76 56L96 76L66 68L78 92L58 72L50 96L42 72L22 92L34 68L4 76L24 56L2 50L24 44L4 24L34 32L22 8L42 28Z',
|
|
86
|
+
boom:
|
|
87
|
+
'M50 0L54 18L62 2L60 20L76 6L68 24L88 14L76 30L98 24L82 38L100 38L86 46L100 50L86 54L100 62L82 62L98 76L76 70L88 86L68 76L76 94L60 80L62 98L54 82L50 100L46 82L38 98L40 80L24 94L32 76L12 86L24 70L2 76L18 62L0 62L14 54L0 50L14 46L0 38L18 38L2 24L24 30L12 14L32 24L24 6L40 20L38 2L46 18Z',
|
|
88
|
+
'soft-boom':
|
|
89
|
+
'M50 4L56 22L68 6L64 26L82 14L74 32L94 24L82 40L100 36L86 48L100 50L86 52L100 64L82 60L94 76L74 68L82 86L64 74L68 94L56 78L50 96L44 78L32 94L36 74L18 86L26 68L6 76L18 60L0 64L14 52L0 50L14 48L0 36L18 40L6 24L26 32L18 14L36 26L32 6L44 22Z',
|
|
90
|
+
flower:
|
|
91
|
+
'M50 4Q62 18 72 10Q82 18 76 32Q90 30 88 44Q98 50 88 56Q90 70 76 68Q82 82 72 90Q62 82 50 96Q38 82 28 90Q18 82 24 68Q10 70 12 56Q2 50 12 44Q10 30 24 32Q18 18 28 10Q38 18 50 4Z',
|
|
92
|
+
puffy:
|
|
93
|
+
'M34 6Q50-4 66 6Q84 6 90 22Q104 34 96 50Q104 66 90 78Q84 94 66 94Q50 104 34 94Q16 94 10 78Q-4 66 4 50Q-4 34 10 22Q16 6 34 6Z',
|
|
94
|
+
'puffy-diamond':
|
|
95
|
+
'M50 2Q66 10 72 4Q86 12 84 28Q98 32 94 48Q100 64 86 70Q88 86 72 88Q62 98 48 94Q34 100 26 86Q12 88 8 72Q-2 62 6 48Q0 32 16 28Q14 12 28 4Q38 10 50 2Z',
|
|
96
|
+
|
|
97
|
+
// --- Fun ---
|
|
98
|
+
'ghost-ish':
|
|
99
|
+
'M20 96V40Q20 4 50 4Q80 4 80 40V96L70 86L60 96L50 86L40 96L30 86Z',
|
|
100
|
+
'pixel-circle':
|
|
101
|
+
'M30 4H70V4H86V14H96V30H100V70H96V86H86V96H70V100H30V96H14V86H4V70H0V30H4V14H14V4H30Z',
|
|
102
|
+
'pixel-triangle':
|
|
103
|
+
'M42 4H58V18H70V32H82V46H94V60H100V100H0V60H6V46H18V32H30V18H42Z',
|
|
104
|
+
bun:
|
|
105
|
+
'M12 54Q-4 24 20 8Q44-6 68 8Q92 22 94 50Q96 72 76 88Q56 100 36 96Q14 90 12 54Z',
|
|
106
|
+
heart:
|
|
107
|
+
'M50 90C25 70 2 55 2 32Q2 10 24 6Q40 2 50 18Q60 2 76 6Q98 10 98 32Q98 55 75 70Z',
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Display labels for the named shapes, in the order a picker should show them.
|
|
112
|
+
* Exported so consumers building a shape picker do not re-derive the order (or
|
|
113
|
+
* the capitalisation) themselves.
|
|
114
|
+
*/
|
|
115
|
+
export const AVATAR_SHAPE_LABELS: { name: AvatarShapeName; label: string }[] = [
|
|
116
|
+
{ name: 'square', label: 'Square' },
|
|
117
|
+
{ name: 'slanted', label: 'Slanted' },
|
|
118
|
+
{ name: 'arch', label: 'Arch' },
|
|
119
|
+
{ name: 'semicircle', label: 'Semicircle' },
|
|
120
|
+
{ name: 'oval', label: 'Oval' },
|
|
121
|
+
{ name: 'pill', label: 'Pill' },
|
|
122
|
+
{ name: 'triangle', label: 'Triangle' },
|
|
123
|
+
{ name: 'arrow', label: 'Arrow' },
|
|
124
|
+
{ name: 'fan', label: 'Fan' },
|
|
125
|
+
{ name: 'diamond', label: 'Diamond' },
|
|
126
|
+
{ name: 'clamshell', label: 'Clamshell' },
|
|
127
|
+
{ name: 'pentagon', label: 'Pentagon' },
|
|
128
|
+
{ name: 'gem', label: 'Gem' },
|
|
129
|
+
{ name: 'very-sunny', label: 'Very Sunny' },
|
|
130
|
+
{ name: 'sunny', label: 'Sunny' },
|
|
131
|
+
{ name: '4-sided-cookie', label: '4-Sided Cookie' },
|
|
132
|
+
{ name: '6-sided-cookie', label: '6-Sided Cookie' },
|
|
133
|
+
{ name: '7-sided-cookie', label: '7-Sided Cookie' },
|
|
134
|
+
{ name: '9-sided-cookie', label: '9-Sided Cookie' },
|
|
135
|
+
{ name: '12-sided-cookie', label: '12-Sided Cookie' },
|
|
136
|
+
{ name: '4-leaf-clover', label: '4-Leaf Clover' },
|
|
137
|
+
{ name: '8-leaf-clover', label: '8-Leaf Clover' },
|
|
138
|
+
{ name: 'burst', label: 'Burst' },
|
|
139
|
+
{ name: 'soft-burst', label: 'Soft Burst' },
|
|
140
|
+
{ name: 'boom', label: 'Boom' },
|
|
141
|
+
{ name: 'soft-boom', label: 'Soft Boom' },
|
|
142
|
+
{ name: 'flower', label: 'Flower' },
|
|
143
|
+
{ name: 'puffy', label: 'Puffy' },
|
|
144
|
+
{ name: 'puffy-diamond', label: 'Puffy Diamond' },
|
|
145
|
+
{ name: 'ghost-ish', label: 'Ghost-ish' },
|
|
146
|
+
{ name: 'pixel-circle', label: 'Pixel Circle' },
|
|
147
|
+
{ name: 'pixel-triangle', label: 'Pixel Triangle' },
|
|
148
|
+
{ name: 'bun', label: 'Bun' },
|
|
149
|
+
{ name: 'heart', label: 'Heart' },
|
|
150
|
+
];
|
package/src/avatar/types.ts
CHANGED
|
@@ -3,7 +3,30 @@ import type { ImageSourcePropType, StyleProp, ViewStyle, ImageStyle } from 'reac
|
|
|
3
3
|
// Referenced by the `variant` prop docs below.
|
|
4
4
|
import type { ImageResolver } from '../image-resolver/context';
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import type { AvatarShapeName } from './shape-paths';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Built-in avatar outlines: the two originals plus the named registry in
|
|
10
|
+
* `shape-paths.ts`. Any of these can be passed as a plain string.
|
|
11
|
+
*/
|
|
12
|
+
export type AvatarShape = 'circle' | 'squircle' | AvatarShapeName;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A caller-supplied outline, for shapes that are not in the built-in registry.
|
|
16
|
+
*
|
|
17
|
+
* The path is clipped against the avatar box, so it should fill its coordinate
|
|
18
|
+
* space edge to edge; anything outside is cut off rather than scaled to fit.
|
|
19
|
+
*/
|
|
20
|
+
export interface AvatarShapePath {
|
|
21
|
+
/** SVG path data (the `d` attribute) describing a closed outline. */
|
|
22
|
+
d: string;
|
|
23
|
+
/**
|
|
24
|
+
* Side length of the square coordinate space `d` is drawn in — 100 for a
|
|
25
|
+
* path written over a 0–100 grid, 1 for one written over a 0–1 grid.
|
|
26
|
+
* Defaults to 1.
|
|
27
|
+
*/
|
|
28
|
+
viewBox?: number;
|
|
29
|
+
}
|
|
7
30
|
|
|
8
31
|
/** Gradient sweep direction for a multi-color {@link AvatarRingConfig}. */
|
|
9
32
|
export type AvatarRingGradientDirection = 'diagonal' | 'horizontal' | 'vertical';
|
|
@@ -45,8 +68,14 @@ export interface AvatarProps {
|
|
|
45
68
|
verified?: boolean;
|
|
46
69
|
/** Custom verified badge icon (rendered at bottom-right) */
|
|
47
70
|
verifiedIcon?: ReactNode;
|
|
48
|
-
/**
|
|
49
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Shape of the avatar (defaults to `'circle'`). Accepts a built-in name —
|
|
73
|
+
* `'squircle'`, `'heart'`, `'pentagon'`, … see {@link AvatarShapeName} — or an
|
|
74
|
+
* {@link AvatarShapePath} with your own outline. Everything except `'circle'`
|
|
75
|
+
* clips through `react-native-svg`; a name that does not resolve falls back to
|
|
76
|
+
* a circle.
|
|
77
|
+
*/
|
|
78
|
+
shape?: AvatarShape | AvatarShapePath;
|
|
50
79
|
/** Container style */
|
|
51
80
|
style?: StyleProp<ViewStyle>;
|
|
52
81
|
/** Image style (circle shape only) */
|
|
@@ -29,6 +29,7 @@ import Animated, {
|
|
|
29
29
|
interpolate,
|
|
30
30
|
interpolateColor,
|
|
31
31
|
runOnJS,
|
|
32
|
+
useAnimatedReaction,
|
|
32
33
|
useAnimatedStyle,
|
|
33
34
|
useSharedValue,
|
|
34
35
|
withSpring,
|
|
@@ -83,6 +84,13 @@ type BarContextValue = {
|
|
|
83
84
|
theme: TabBarTheme;
|
|
84
85
|
/** Set only on the CONTROLLED path (see `TabBarProps.activeIndex`). */
|
|
85
86
|
activeIndex: number | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* True while a driver owns the highlight's POSITION (see
|
|
89
|
+
* `TabBarProps.activeProgress`). Buttons read it to stand down: their focus
|
|
90
|
+
* effect and their press must not spring `slideIndex` out from under a pager
|
|
91
|
+
* mid-gesture. It says nothing about visibility, which stays where it was.
|
|
92
|
+
*/
|
|
93
|
+
driven: boolean;
|
|
86
94
|
/** Report a selection made by a button press. */
|
|
87
95
|
selectIndex: (index: number) => void;
|
|
88
96
|
};
|
|
@@ -103,6 +111,7 @@ function TabBarBody({
|
|
|
103
111
|
Blur,
|
|
104
112
|
children,
|
|
105
113
|
activeIndex,
|
|
114
|
+
activeProgress,
|
|
106
115
|
onIndexChange,
|
|
107
116
|
onIndexLongPress,
|
|
108
117
|
theme: themeOverrides,
|
|
@@ -189,6 +198,33 @@ function TabBarBody({
|
|
|
189
198
|
// a legitimate effect (external-system sync), the same shape `Tabs` uses for
|
|
190
199
|
// its underline. The focus-driven path is handled per-button instead — see
|
|
191
200
|
// `TabBarButtonBody`.
|
|
201
|
+
// DRIVEN path: a pager owns the highlight's position and writes it every
|
|
202
|
+
// frame, so the position is COPIED rather than sprung — a spring chasing a
|
|
203
|
+
// per-frame value lags behind the finger, which is the whole thing this path
|
|
204
|
+
// exists to avoid.
|
|
205
|
+
//
|
|
206
|
+
// `useAnimatedReaction` cannot be called conditionally, so an unsupplied
|
|
207
|
+
// `activeProgress` is stood in for by a shared value nothing ever writes and
|
|
208
|
+
// the reaction returns early. `driven` changing flips the identity of `source`
|
|
209
|
+
// too, and both are in the deps array, so the reaction re-registers either
|
|
210
|
+
// way.
|
|
211
|
+
//
|
|
212
|
+
// Scrubbing still wins: a finger on the BAR is a direct manipulation of the
|
|
213
|
+
// bar and must not be fought by the pager's settle animation.
|
|
214
|
+
// (Deps: see the CRITICAL note below.)
|
|
215
|
+
const idleProgress = useSharedValue(0);
|
|
216
|
+
const driven = activeProgress !== undefined;
|
|
217
|
+
const source = activeProgress ?? idleProgress;
|
|
218
|
+
useAnimatedReaction(
|
|
219
|
+
() => source.value,
|
|
220
|
+
(value) => {
|
|
221
|
+
if (!driven) return;
|
|
222
|
+
if (isDragging.value) return;
|
|
223
|
+
slideIndex.value = value;
|
|
224
|
+
},
|
|
225
|
+
[source, driven, isDragging, slideIndex],
|
|
226
|
+
);
|
|
227
|
+
|
|
192
228
|
useEffect(() => {
|
|
193
229
|
if (activeIndex === undefined) return;
|
|
194
230
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
@@ -209,10 +245,17 @@ function TabBarBody({
|
|
|
209
245
|
// stale index would animate out of a position the user never saw, and would
|
|
210
246
|
// light up every tab in between on the way. Interrupted mid-fade it is
|
|
211
247
|
// still on screen, so from there it slides as it always does.
|
|
212
|
-
|
|
213
|
-
|
|
248
|
+
//
|
|
249
|
+
// On the DRIVEN path this effect keeps owning visibility and gives up the
|
|
250
|
+
// position: the driver is already writing `slideIndex` every frame, and a
|
|
251
|
+
// spring started here would drag the capsule across the bar between two of
|
|
252
|
+
// its writes.
|
|
253
|
+
if (!driven) {
|
|
254
|
+
slideIndex.value =
|
|
255
|
+
highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
|
|
256
|
+
}
|
|
214
257
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
215
|
-
}, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
|
|
258
|
+
}, [activeIndex, hasSelection, driven, slideIndex, highlightOpacity, isDragging]);
|
|
216
259
|
|
|
217
260
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
218
261
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
@@ -292,8 +335,15 @@ function TabBarBody({
|
|
|
292
335
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
293
336
|
// Same rule as the controlled path above: appear at the tapped tab when
|
|
294
337
|
// hidden, slide to it when already on screen.
|
|
295
|
-
|
|
296
|
-
|
|
338
|
+
//
|
|
339
|
+
// A tap is a discrete writer, so on the DRIVEN path it stands down like
|
|
340
|
+
// the others and the driver carries the highlight to the tapped tab.
|
|
341
|
+
// The scrub is the one gesture that does not: a finger dragging the
|
|
342
|
+
// pill is manipulating the bar itself, not asking for a page.
|
|
343
|
+
if (!driven) {
|
|
344
|
+
slideIndex.value =
|
|
345
|
+
highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
346
|
+
}
|
|
297
347
|
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
298
348
|
setMinimized(minimized, 0);
|
|
299
349
|
runOnJS(selectIndex)(index);
|
|
@@ -324,6 +374,7 @@ function TabBarBody({
|
|
|
324
374
|
hasLongPress,
|
|
325
375
|
longPressIndex,
|
|
326
376
|
tick,
|
|
377
|
+
driven,
|
|
327
378
|
isDragging,
|
|
328
379
|
lastTicked,
|
|
329
380
|
slideIndex,
|
|
@@ -444,8 +495,8 @@ function TabBarBody({
|
|
|
444
495
|
const constrainedWrapStyle: ViewStyle | null =
|
|
445
496
|
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
446
497
|
const barContext = useMemo(
|
|
447
|
-
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
448
|
-
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
498
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex }),
|
|
499
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, driven, selectIndex],
|
|
449
500
|
);
|
|
450
501
|
|
|
451
502
|
return (
|
|
@@ -522,6 +573,10 @@ function TabBarButtonBody({
|
|
|
522
573
|
useEffect(() => {
|
|
523
574
|
if (isFocused === undefined || !isFocused || !bar) return;
|
|
524
575
|
if (bar.isDragging.value) return;
|
|
576
|
+
// A driver owns the position — see `TabBarProps.activeProgress`. It is
|
|
577
|
+
// already tracking this same focus change; springing from here as well would
|
|
578
|
+
// be a second writer on one shared value.
|
|
579
|
+
if (bar.driven) return;
|
|
525
580
|
bar.slideIndex.value = withSpring(index, SLIDE_SPRING);
|
|
526
581
|
}, [isFocused, index, bar]);
|
|
527
582
|
|
|
@@ -589,9 +644,13 @@ function TabBarButtonBody({
|
|
|
589
644
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
590
645
|
if (bar) {
|
|
591
646
|
// Appear at the tab when hidden, slide to it when visible — the same
|
|
592
|
-
// rule the tap gesture and the controlled path follow.
|
|
593
|
-
|
|
594
|
-
|
|
647
|
+
// rule the tap gesture and the controlled path follow. Skipped on the
|
|
648
|
+
// driven path, where the position belongs to the driver and this
|
|
649
|
+
// press will reach it as an ordinary selection.
|
|
650
|
+
if (!bar.driven) {
|
|
651
|
+
bar.slideIndex.value =
|
|
652
|
+
bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
653
|
+
}
|
|
595
654
|
bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
596
655
|
}
|
|
597
656
|
setMinimized(minimized, 0);
|
|
@@ -19,6 +19,11 @@ import type { TabBarItem, TabBarProps } from '../types';
|
|
|
19
19
|
* by each trigger's own focus (see `RouterTabBarButton`), which is what keeps it
|
|
20
20
|
* correct through deep links, back gestures and any other programmatic
|
|
21
21
|
* navigation. Setting both would put two writers on one shared value.
|
|
22
|
+
*
|
|
23
|
+
* `activeProgress` is NOT omitted, and that is not an oversight. It answers a
|
|
24
|
+
* different question — where the highlight is, not whether there is one — so a
|
|
25
|
+
* router-focused bar whose screens are PAGED can pass it and keep the focus
|
|
26
|
+
* path for selection. See `TabBarProps.activeProgress`.
|
|
22
27
|
*/
|
|
23
28
|
export type RouterTabBarProps = TabListProps & Omit<TabBarProps, 'activeIndex'>;
|
|
24
29
|
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Public API types for `@oxyhq/bloom/tab-bar`.
|
|
6
6
|
*
|
|
7
|
-
* This module
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* three at once. Platform behaviour lives in `surface.native.tsx` /
|
|
11
|
-
* `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter
|
|
7
|
+
* This module names no PLATFORM package. It is resolved by a consumer's `tsc`,
|
|
8
|
+
* by Metro, and by every web bundler alike, so an import here of
|
|
9
|
+
* expo-glass-effect / expo-symbols / expo-router / react-native-screens would
|
|
10
|
+
* break all three at once. Platform behaviour lives in `surface.native.tsx` /
|
|
11
|
+
* `glyph.native.tsx` / `surface.web.tsx` and the `expo-router/` adapter; the
|
|
12
|
+
* gate is `src/__tests__/TabBarWebFork.test.ts`.
|
|
13
|
+
*
|
|
14
|
+
* `react-native-reanimated` is exempt and always was: it is a REQUIRED peer that
|
|
15
|
+
* every neutral module in this family already imports at runtime
|
|
16
|
+
* (`TabBarBase.tsx`), and the one reference below is `import type`, erased
|
|
17
|
+
* before any bundler sees it.
|
|
12
18
|
*/
|
|
13
19
|
import type { ReactNode } from 'react';
|
|
14
20
|
import type { PressableProps, ViewProps } from 'react-native';
|
|
21
|
+
import type { SharedValue } from 'react-native-reanimated';
|
|
15
22
|
|
|
16
23
|
export type TabBarItem = {
|
|
17
24
|
/** Stable identity for the tab (a route name in router-driven usage). */
|
|
@@ -92,6 +99,30 @@ export type TabBarProps = ViewProps & {
|
|
|
92
99
|
* highlight from its own `isFocused`.
|
|
93
100
|
*/
|
|
94
101
|
activeIndex?: number;
|
|
102
|
+
/**
|
|
103
|
+
* DRIVEN path — the highlight's live position, in TAB UNITS, on the UI thread.
|
|
104
|
+
* A pager writes its own page position here (`1.4` is 40% of the way from the
|
|
105
|
+
* second tab to the third) and the highlight tracks the finger 1:1 instead of
|
|
106
|
+
* springing to a settled index after the navigation commits.
|
|
107
|
+
*
|
|
108
|
+
* It says WHERE the highlight is, never WHETHER there is one. Selection stays
|
|
109
|
+
* with the path already in play: `activeIndex` when the bar is controlled (an
|
|
110
|
+
* index naming no tab still fades the highlight out — see above), each
|
|
111
|
+
* button's `isFocused` when it is not. Passing this alone, with neither, is a
|
|
112
|
+
* bar that is always selected, which is what the focus-driven path already
|
|
113
|
+
* means.
|
|
114
|
+
*
|
|
115
|
+
* PRECEDENCE, because a shared value with two writers is a race: the SCRUB
|
|
116
|
+
* always wins — a finger on the bar is a direct manipulation of the bar, and
|
|
117
|
+
* releasing it reports through `onIndexChange` like any other selection. Below
|
|
118
|
+
* that, this value wins over every discrete writer: while it is supplied, the
|
|
119
|
+
* controlled effect, the focus effect and a `TabBarButton`'s own press all
|
|
120
|
+
* stop writing the position and leave it to the driver. So a consumer that
|
|
121
|
+
* passes this must keep it truthful — spring it to the settled index after a
|
|
122
|
+
* commit, and after any navigation it did not cause (a deep link, a back
|
|
123
|
+
* gesture), or the highlight stays where the last gesture left it.
|
|
124
|
+
*/
|
|
125
|
+
activeProgress?: SharedValue<number>;
|
|
95
126
|
/**
|
|
96
127
|
* Called with the chosen index when a tab is picked by tap, by scrub release,
|
|
97
128
|
* or by keyboard/assistive-technology activation of a `TabBarButton`.
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A barrel that DELIBERATELY offers one name from two declarations, so that
|
|
3
|
-
* `barrel-name-collisions.test.ts` can prove its detector fires. It reproduces
|
|
4
|
-
* the shipped `Item` shape: a star export carrying `Item` from `item/`, and an
|
|
5
|
-
* explicit re-export binding that same name to a different component.
|
|
6
|
-
*
|
|
7
|
-
* Nothing imports this at runtime. bob's `exclude` keeps it out of `lib/`, and
|
|
8
|
-
* `files: ["src", …]` does ship it inside the tarball's `src/` like the other
|
|
9
|
-
* test files there — but no entry point reaches it, so no bundler links it, and
|
|
10
|
-
* `package.json#exports` never names it.
|
|
11
|
-
*
|
|
12
|
-
* The `Card` lines below are the other half of the
|
|
13
|
-
* control: one name offered twice from ONE declaration, which the detector must
|
|
14
|
-
* stay quiet about — otherwise its cheapest fix would be deleting a legitimate
|
|
15
|
-
* redundant re-export.
|
|
16
|
-
*/
|
|
17
|
-
export * from '../../item';
|
|
18
|
-
export { Card as Item } from '../../card';
|
|
19
|
-
export { Card } from '../../card';
|
|
20
|
-
export * from '../../card';
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A spec-shaped stand-in for constructed stylesheets, for the jsdom suites.
|
|
3
|
-
*
|
|
4
|
-
* jsdom 26 ships the `CSSStyleSheet` CONSTRUCTOR but neither `replaceSync` nor
|
|
5
|
-
* `document.adoptedStyleSheets`, so every jsdom test takes Bloom's `<style>`
|
|
6
|
-
* fallback. That makes the whole CSP-safe path — the reason
|
|
7
|
-
* `styles/adopt-style-sheet.ts` exists — invisible to jest unless a test
|
|
8
|
-
* installs the API itself. Without this, a mutation that deleted the adoption
|
|
9
|
-
* branch entirely would leave the suite green.
|
|
10
|
-
*
|
|
11
|
-
* Not collected as a suite: jest's `testMatch` wants `*.test.ts` / `*.spec.ts`.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
export interface FakeStyleSheet {
|
|
15
|
-
/** The CSS most recently handed to `replaceSync`. */
|
|
16
|
-
cssText: string;
|
|
17
|
-
/** How many times the sheet has been re-parsed, so a test can prove it wasn't. */
|
|
18
|
-
replaceSyncCalls: number;
|
|
19
|
-
replaceSync(css: string): void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface ConstructedStyleSheetsHarness {
|
|
23
|
-
/** The document's adopted sheets, read live (the code reassigns the array). */
|
|
24
|
-
adopted(): readonly FakeStyleSheet[];
|
|
25
|
-
/** Drop the API again, restoring jsdom's own `CSSStyleSheet`. */
|
|
26
|
-
uninstall(): void;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function installConstructedStyleSheets(): ConstructedStyleSheetsHarness {
|
|
30
|
-
class FakeCSSStyleSheet implements FakeStyleSheet {
|
|
31
|
-
cssText = '';
|
|
32
|
-
replaceSyncCalls = 0;
|
|
33
|
-
|
|
34
|
-
replaceSync(css: string): void {
|
|
35
|
-
this.cssText = css;
|
|
36
|
-
this.replaceSyncCalls += 1;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const previousConstructor = Object.getOwnPropertyDescriptor(
|
|
41
|
-
globalThis,
|
|
42
|
-
'CSSStyleSheet',
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
Object.defineProperty(globalThis, 'CSSStyleSheet', {
|
|
46
|
-
value: FakeCSSStyleSheet,
|
|
47
|
-
writable: true,
|
|
48
|
-
configurable: true,
|
|
49
|
-
});
|
|
50
|
-
Object.defineProperty(document, 'adoptedStyleSheets', {
|
|
51
|
-
value: [],
|
|
52
|
-
writable: true,
|
|
53
|
-
configurable: true,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
adopted: () =>
|
|
58
|
-
document.adoptedStyleSheets as unknown as readonly FakeStyleSheet[],
|
|
59
|
-
uninstall: () => {
|
|
60
|
-
Reflect.deleteProperty(document, 'adoptedStyleSheets');
|
|
61
|
-
if (previousConstructor) {
|
|
62
|
-
Object.defineProperty(globalThis, 'CSSStyleSheet', previousConstructor);
|
|
63
|
-
} else {
|
|
64
|
-
Reflect.deleteProperty(globalThis, 'CSSStyleSheet');
|
|
65
|
-
}
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pressing a component's OWN host node, and proving the component is what
|
|
3
|
-
* installed the handler being measured.
|
|
4
|
-
*
|
|
5
|
-
* `fireEvent.press` walks UP from the element it is handed to the nearest
|
|
6
|
-
* ancestor carrying an `onPress` prop, and it does not stop at host nodes — a
|
|
7
|
-
* COMPOSITE counts, because `getEventHandler` only reads `element.props`. So
|
|
8
|
-
* `<Thing onPress={fn}>` written in a test's own JSX catches the press itself.
|
|
9
|
-
* Measured on `ProfileCard`: with the handler deleted from the component the
|
|
10
|
-
* card rendered as an inert `View`, and the test still reported exactly one
|
|
11
|
-
* call. Green, and measuring nothing. Nine suites had the same shape.
|
|
12
|
-
*
|
|
13
|
-
* Asserting the host node's own `onPress` FIRST closes both halves:
|
|
14
|
-
*
|
|
15
|
-
* - a component that installs no handler at all fails here, and
|
|
16
|
-
* - a component that installs one which drops the caller's fails on the call
|
|
17
|
-
* count, because `fireEvent` now finds a handler AT the node and never walks
|
|
18
|
-
* past it to the test's own JSX.
|
|
19
|
-
*
|
|
20
|
-
* `host` must therefore be the node the component itself made pressable — the
|
|
21
|
-
* one carrying its `testID` / `accessibilityLabel` — not a `Text` deep inside
|
|
22
|
-
* it, or the walk-up is back and so is the hole.
|
|
23
|
-
*/
|
|
24
|
-
import { fireEvent } from '@testing-library/react-native';
|
|
25
|
-
import type { ReactTestInstance } from 'react-test-renderer';
|
|
26
|
-
|
|
27
|
-
export function pressHost(host: ReactTestInstance): void {
|
|
28
|
-
expect(typeof host.props.onPress).toBe('function');
|
|
29
|
-
fireEvent.press(host);
|
|
30
|
-
}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading what actually landed on a rendered node — shared by every suite that
|
|
3
|
-
* asserts the "className lands on the node the parent lays out" rule.
|
|
4
|
-
*
|
|
5
|
-
* Three components had the same two-node defect (`Button`, then `Fab` and
|
|
6
|
-
* `FrostedIconButton`), and asserting it needs the same two things each time:
|
|
7
|
-
* the HOST tree (composites do not appear in `toJSON()`, so a wrapper is only
|
|
8
|
-
* visible there) and a deep flatten of the `style` prop, because react-native-css
|
|
9
|
-
* merges a caller's class in as a `{ $$css: true, className }` descriptor
|
|
10
|
-
* ALONGSIDE the style objects rather than in a prop of its own.
|
|
11
|
-
*/
|
|
12
|
-
import type { ReactTestRendererJSON } from 'react-test-renderer';
|
|
13
|
-
|
|
14
|
-
export type StyleEntry = Record<string, unknown>;
|
|
15
|
-
|
|
16
|
-
export interface HostNode {
|
|
17
|
-
type: string;
|
|
18
|
-
props: Record<string, unknown>;
|
|
19
|
-
children: Array<HostNode | string> | null;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Deep-flatten an RN `style` prop (nested arrays, `false`/`null` holes) into the
|
|
24
|
-
* list of style objects that actually reached the node.
|
|
25
|
-
*/
|
|
26
|
-
export function styleEntries(style: unknown, out: StyleEntry[] = []): StyleEntry[] {
|
|
27
|
-
if (!style || typeof style !== 'object') return out;
|
|
28
|
-
if (Array.isArray(style)) {
|
|
29
|
-
for (const entry of style) styleEntries(entry, out);
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
out.push(style as StyleEntry);
|
|
33
|
-
return out;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Every style key/value that landed on a node, later entries winning. */
|
|
37
|
-
export function resolvedStyle(style: unknown): StyleEntry {
|
|
38
|
-
return Object.assign({}, ...styleEntries(style)) as StyleEntry;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** The class tokens react-native-css accepted for a node, in arrival order. */
|
|
42
|
-
export function classNamesOn(style: unknown): string[] {
|
|
43
|
-
return styleEntries(style)
|
|
44
|
-
.filter((entry) => entry.$$css === true && typeof entry.className === 'string')
|
|
45
|
-
.map((entry) => String(entry.className));
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function isHostNode(value: unknown): value is HostNode {
|
|
49
|
-
return (
|
|
50
|
-
typeof value === 'object' && value !== null && typeof (value as HostNode).type === 'string'
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Walk the HOST tree (composites are absent from `toJSON()`) for a testID. */
|
|
55
|
-
export function findHost(
|
|
56
|
-
node: ReactTestRendererJSON | ReactTestRendererJSON[] | unknown,
|
|
57
|
-
testID: string,
|
|
58
|
-
): HostNode | null {
|
|
59
|
-
if (Array.isArray(node)) {
|
|
60
|
-
for (const child of node) {
|
|
61
|
-
const hit = findHost(child, testID);
|
|
62
|
-
if (hit) return hit;
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
if (!isHostNode(node)) return null;
|
|
67
|
-
if (node.props.testID === testID) return node;
|
|
68
|
-
return findHost(node.children, testID);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Every host node in a rendered tree, in document order.
|
|
73
|
-
*
|
|
74
|
-
* The reason to walk `toJSON()` rather than `UNSAFE_root.findAll` is that the
|
|
75
|
-
* latter yields COMPOSITE instances too, so a `memo(fn)` component contributes
|
|
76
|
-
* two matches for one rendered element and every count is silently doubled —
|
|
77
|
-
* which reads as "the component rendered the thing twice", not as an artefact
|
|
78
|
-
* of the query.
|
|
79
|
-
*/
|
|
80
|
-
export function hostNodes(tree: unknown, out: HostNode[] = []): HostNode[] {
|
|
81
|
-
if (Array.isArray(tree)) {
|
|
82
|
-
for (const child of tree) hostNodes(child, out);
|
|
83
|
-
return out;
|
|
84
|
-
}
|
|
85
|
-
if (!isHostNode(tree)) return out;
|
|
86
|
-
out.push(tree);
|
|
87
|
-
return hostNodes(tree.children, out);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The host nodes a parent laid out. Callers assert the LENGTH themselves —
|
|
92
|
-
* "exactly one" is the property, and a helper that returned only the first would
|
|
93
|
-
* hide the second.
|
|
94
|
-
*/
|
|
95
|
-
export function renderedChildren(tree: unknown, hostTestID: string): HostNode[] {
|
|
96
|
-
const host = findHost(tree, hostTestID);
|
|
97
|
-
if (host === null) throw new Error(`no host rendered for testID "${hostTestID}"`);
|
|
98
|
-
return (host.children ?? []).filter(isHostNode);
|
|
99
|
-
}
|