expo-interface 0.1.1 → 0.3.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 +119 -20
- package/package.json +9 -9
- package/src/accent.tsx +21 -8
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +12 -0
- package/src/button/index.android.tsx +14 -3
- package/src/button/index.ios.tsx +13 -4
- package/src/button/index.tsx +8 -1
- package/src/button/types.ts +20 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +136 -0
- package/src/color-picker/index.android.tsx +145 -0
- package/src/color-picker/index.ios.tsx +80 -0
- package/src/color-picker/index.tsx +112 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +40 -0
- package/src/context-menu/index.android.tsx +39 -11
- package/src/context-menu/index.ios.tsx +3 -1
- package/src/context-menu/index.tsx +24 -7
- package/src/fab/fab.css +72 -0
- package/src/fab/index.android.tsx +72 -0
- package/src/fab/index.ios.tsx +68 -0
- package/src/fab/index.tsx +37 -0
- package/src/fab/shared.ts +23 -0
- package/src/fab/types.ts +39 -0
- package/src/field-group/field-group.css +23 -7
- package/src/field-group/index.android.tsx +44 -38
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +27 -11
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +35 -6
- package/src/keyboard/index.tsx +81 -0
- package/src/keyboard/library.native.ts +17 -0
- package/src/keyboard/library.ts +12 -0
- package/src/keyboard/types.ts +20 -0
- package/src/list-item/index.android.tsx +23 -6
- package/src/list-item/index.tsx +20 -4
- package/src/list-item/index.web.tsx +60 -0
- package/src/list-item/list-item.css +65 -0
- package/src/list-item/types.ts +21 -0
- package/src/menu/index.android.tsx +22 -9
- package/src/menu/index.ios.tsx +26 -11
- package/src/menu/index.tsx +26 -10
- package/src/menu/list.tsx +25 -10
- package/src/menu/menu.css +52 -0
- package/src/menu/types.ts +41 -2
- package/src/progress/progress.css +9 -0
- package/src/scheme.ts +140 -0
- package/src/screen/header.tsx +1 -0
- package/src/screen/index.tsx +49 -5
- package/src/segmented/index.tsx +1 -1
- package/src/segmented/segmented.css +6 -7
- package/src/slider/index.tsx +1 -1
- package/src/stepper/index.tsx +1 -1
- package/src/switch/index.tsx +2 -0
- package/src/tab-stack/index.tsx +15 -2
- package/src/tabs/index.tsx +2 -1
- package/src/tabs/index.web.tsx +29 -4
- package/src/tabs/types.ts +20 -2
- package/src/text-field/index.android.tsx +30 -6
- package/src/text-field/index.ios.tsx +16 -3
- package/src/text-field/index.tsx +24 -4
- package/src/text-field/inline.tsx +87 -0
- package/src/text-field/shared.ts +40 -1
- package/src/text-field/types.ts +47 -2
- package/src/theme.ts +44 -5
- package/src/qr/index.tsx +0 -26
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SwiftUI gauge styles redrawn on web. Sizes are the points measured from
|
|
3
|
+
* iOS (see shared.ts); the unfilled tracks are iOS `tertiarySystemFill`
|
|
4
|
+
* (automatic) and `systemFill` (linearCapacity), which `light-dark()`
|
|
5
|
+
* resolves per scheme.
|
|
6
|
+
*/
|
|
7
|
+
.ui-gauge,
|
|
8
|
+
.ui-gauge-ring {
|
|
9
|
+
--ui-gauge-accent: var(--color-tint);
|
|
10
|
+
--ui-gauge-knockout: var(--color-background);
|
|
11
|
+
--ui-gauge-track: light-dark(rgba(118, 118, 128, 0.12), rgba(118, 118, 128, 0.24));
|
|
12
|
+
--ui-gauge-fill-track: light-dark(rgba(120, 120, 128, 0.2), rgba(120, 120, 128, 0.36));
|
|
13
|
+
box-sizing: border-box;
|
|
14
|
+
font-family: var(--font-display);
|
|
15
|
+
font-size: 17px;
|
|
16
|
+
line-height: 22px;
|
|
17
|
+
color: var(--color-label);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ui-gauge {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
align-items: center;
|
|
24
|
+
width: 100%;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ui-gauge__label {
|
|
28
|
+
color: var(--color-label);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.ui-gauge__bound,
|
|
32
|
+
.ui-gauge__current {
|
|
33
|
+
color: var(--ui-gauge-accent);
|
|
34
|
+
white-space: nowrap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ui-gauge__row {
|
|
38
|
+
display: flex;
|
|
39
|
+
flex-direction: row;
|
|
40
|
+
align-items: center;
|
|
41
|
+
gap: 8px;
|
|
42
|
+
width: 100%;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.ui-gauge__track {
|
|
46
|
+
position: relative;
|
|
47
|
+
display: block;
|
|
48
|
+
flex: 1;
|
|
49
|
+
min-width: 0;
|
|
50
|
+
border-radius: 999px;
|
|
51
|
+
overflow: hidden;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.ui-gauge__fill {
|
|
55
|
+
display: block;
|
|
56
|
+
height: 100%;
|
|
57
|
+
border-radius: 999px;
|
|
58
|
+
background: var(--ui-gauge-accent);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* automatic: thick capacity bar, label centered above, value centered below */
|
|
62
|
+
.ui-gauge--automatic .ui-gauge__label {
|
|
63
|
+
margin-bottom: 14px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.ui-gauge--automatic .ui-gauge__track {
|
|
67
|
+
height: 15px;
|
|
68
|
+
background: var(--ui-gauge-track);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ui-gauge--automatic .ui-gauge__current {
|
|
72
|
+
margin-top: 9px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* linear (accessoryLinear): tinted bar with a knocked-out point marker */
|
|
76
|
+
.ui-gauge--linear {
|
|
77
|
+
flex-direction: row;
|
|
78
|
+
gap: 8px;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The track is as tall as the marker so the knockout can overhang the bar. */
|
|
82
|
+
.ui-gauge--linear .ui-gauge__track {
|
|
83
|
+
height: 15px;
|
|
84
|
+
border-radius: 0;
|
|
85
|
+
overflow: visible;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.ui-gauge--linear .ui-gauge__track::before {
|
|
89
|
+
content: '';
|
|
90
|
+
position: absolute;
|
|
91
|
+
inset: 3.75px 0;
|
|
92
|
+
border-radius: 999px;
|
|
93
|
+
background: var(--ui-gauge-accent);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.ui-gauge__marker {
|
|
97
|
+
position: absolute;
|
|
98
|
+
top: 50%;
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: center;
|
|
102
|
+
width: 15px;
|
|
103
|
+
height: 15px;
|
|
104
|
+
border-radius: 50%;
|
|
105
|
+
background: var(--ui-gauge-knockout);
|
|
106
|
+
transform: translate(-50%, -50%);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ui-gauge__dot {
|
|
110
|
+
width: 7.5px;
|
|
111
|
+
height: 7.5px;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
background: var(--ui-gauge-accent);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/*
|
|
117
|
+
* linearCapacity (accessoryLinearCapacity): thin bar in a label/bar/value
|
|
118
|
+
* stack. The grid columns and rows are set inline per rendered label.
|
|
119
|
+
*/
|
|
120
|
+
.ui-gauge--linear-capacity {
|
|
121
|
+
display: grid;
|
|
122
|
+
align-items: center;
|
|
123
|
+
column-gap: 8px;
|
|
124
|
+
row-gap: 4px;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.ui-gauge--linear-capacity .ui-gauge__label,
|
|
128
|
+
.ui-gauge--linear-capacity .ui-gauge__current {
|
|
129
|
+
justify-self: start;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.ui-gauge--linear-capacity .ui-gauge__track {
|
|
133
|
+
height: 4px;
|
|
134
|
+
background: var(--ui-gauge-fill-track);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.ui-gauge--linear-capacity .ui-gauge__current {
|
|
138
|
+
font-size: 12px;
|
|
139
|
+
line-height: 16px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* circular styles: 58pt ring drawn in SVG with HTML labels over it */
|
|
143
|
+
.ui-gauge-ring {
|
|
144
|
+
position: relative;
|
|
145
|
+
flex-shrink: 0;
|
|
146
|
+
display: inline-block;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.ui-gauge-ring__svg {
|
|
150
|
+
position: absolute;
|
|
151
|
+
inset: 0;
|
|
152
|
+
display: block;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.ui-gauge-ring__arc,
|
|
156
|
+
.ui-gauge-ring__fill {
|
|
157
|
+
stroke: var(--ui-gauge-accent);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.ui-gauge-ring__track {
|
|
161
|
+
stroke: var(--ui-gauge-accent);
|
|
162
|
+
opacity: 0.3;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.ui-gauge-ring__knockout {
|
|
166
|
+
fill: var(--ui-gauge-knockout);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.ui-gauge-ring__dot {
|
|
170
|
+
fill: var(--ui-gauge-accent);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.ui-gauge-ring__center {
|
|
174
|
+
position: absolute;
|
|
175
|
+
inset: 0;
|
|
176
|
+
display: flex;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: center;
|
|
179
|
+
font-size: 24px;
|
|
180
|
+
line-height: 29px;
|
|
181
|
+
color: var(--ui-gauge-accent);
|
|
182
|
+
white-space: nowrap;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.ui-gauge-ring__center--label {
|
|
186
|
+
color: var(--color-label);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* Centered 17px below the ring center (29 + 17 - half the 12px line). */
|
|
190
|
+
.ui-gauge-ring__bounds {
|
|
191
|
+
position: absolute;
|
|
192
|
+
top: 40px;
|
|
193
|
+
left: 12px;
|
|
194
|
+
display: flex;
|
|
195
|
+
justify-content: space-between;
|
|
196
|
+
width: 34px;
|
|
197
|
+
font-size: 10px;
|
|
198
|
+
line-height: 12px;
|
|
199
|
+
color: var(--ui-gauge-accent);
|
|
200
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type {GaugeProps} from './types';
|
|
2
|
+
import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
|
|
3
|
+
|
|
4
|
+
import {useColorScheme} from 'react-native';
|
|
5
|
+
import {Box, CircularProgressIndicator, Column, Row, Spacer, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
6
|
+
import {
|
|
7
|
+
alpha,
|
|
8
|
+
background,
|
|
9
|
+
clip,
|
|
10
|
+
fillMaxHeight,
|
|
11
|
+
fillMaxWidth,
|
|
12
|
+
height,
|
|
13
|
+
offset,
|
|
14
|
+
padding,
|
|
15
|
+
rotate,
|
|
16
|
+
Shapes,
|
|
17
|
+
size,
|
|
18
|
+
testID as testIDModifier,
|
|
19
|
+
weight,
|
|
20
|
+
width,
|
|
21
|
+
} from '@expo/ui/jetpack-compose/modifiers';
|
|
22
|
+
import {useColor} from '../theme';
|
|
23
|
+
import {fraction, gauge, markerOffset, track, withAlpha} from './shared';
|
|
24
|
+
|
|
25
|
+
const pill = Shapes.RoundedCorner(999);
|
|
26
|
+
const circle = Shapes.Circle;
|
|
27
|
+
const body = {fontSize: gauge.fontSize, lineHeight: gauge.lineHeight};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Android redraws each SwiftUI gauge style out of Compose primitives (the
|
|
31
|
+
* rings are Material 3 `CircularProgressIndicator`s, the bars clipped
|
|
32
|
+
* `Box`es) with the geometry measured from iOS. The live accent seed tints
|
|
33
|
+
* the indicator and the value labels, matching the iOS tint cascade; the
|
|
34
|
+
* descriptive label keeps the surface text color.
|
|
35
|
+
*/
|
|
36
|
+
export function Gauge({
|
|
37
|
+
value,
|
|
38
|
+
min = 0,
|
|
39
|
+
max = 1,
|
|
40
|
+
variant = 'automatic',
|
|
41
|
+
label,
|
|
42
|
+
currentValueLabel,
|
|
43
|
+
minimumValueLabel,
|
|
44
|
+
maximumValueLabel,
|
|
45
|
+
accentColor,
|
|
46
|
+
testID,
|
|
47
|
+
}: GaugeProps) {
|
|
48
|
+
const colors = useMaterialColors();
|
|
49
|
+
const scheme = useColorScheme() === 'dark' ? 'dark' : 'light';
|
|
50
|
+
const tint = useColor('tint');
|
|
51
|
+
const knockout = useColor('background');
|
|
52
|
+
const accent = accentColor ?? tint;
|
|
53
|
+
const f = fraction(value, min, max);
|
|
54
|
+
const testMods: ModifierConfig[] = testID ? [testIDModifier(testID)] : [];
|
|
55
|
+
|
|
56
|
+
const minText = minimumValueLabel != null
|
|
57
|
+
? <Text color={accent} style={body}>{minimumValueLabel}</Text>
|
|
58
|
+
: null;
|
|
59
|
+
const maxText = maximumValueLabel != null
|
|
60
|
+
? <Text color={accent} style={body}>{maximumValueLabel}</Text>
|
|
61
|
+
: null;
|
|
62
|
+
|
|
63
|
+
if (variant === 'circular' || variant === 'circularCapacity') {
|
|
64
|
+
const {size: ring, stroke} = gauge.ring;
|
|
65
|
+
const center = currentValueLabel ?? label;
|
|
66
|
+
const marker = markerOffset(f);
|
|
67
|
+
return (
|
|
68
|
+
<Box contentAlignment="center" modifiers={[size(ring, ring), ...testMods]}>
|
|
69
|
+
{variant === 'circular' ? (
|
|
70
|
+
<CircularProgressIndicator
|
|
71
|
+
progress={gauge.ring.arcSweep / 360}
|
|
72
|
+
color={accent}
|
|
73
|
+
trackColor="#00000000"
|
|
74
|
+
strokeWidth={stroke}
|
|
75
|
+
strokeCap="round"
|
|
76
|
+
gapSize={0}
|
|
77
|
+
modifiers={[size(ring, ring), rotate(gauge.ring.arcStart - 270)]}
|
|
78
|
+
/>
|
|
79
|
+
) : (
|
|
80
|
+
<CircularProgressIndicator
|
|
81
|
+
progress={f}
|
|
82
|
+
color={accent}
|
|
83
|
+
trackColor={withAlpha(accent, gauge.ring.trackOpacity)}
|
|
84
|
+
strokeWidth={stroke}
|
|
85
|
+
strokeCap="round"
|
|
86
|
+
gapSize={0}
|
|
87
|
+
modifiers={[size(ring, ring)]}
|
|
88
|
+
/>
|
|
89
|
+
)}
|
|
90
|
+
{variant === 'circular' ? (
|
|
91
|
+
<Box
|
|
92
|
+
contentAlignment="center"
|
|
93
|
+
modifiers={[
|
|
94
|
+
offset(marker.x, marker.y),
|
|
95
|
+
size(gauge.ring.knockout, gauge.ring.knockout),
|
|
96
|
+
clip(circle),
|
|
97
|
+
background(knockout),
|
|
98
|
+
]}>
|
|
99
|
+
<Box modifiers={[size(gauge.ring.dot, gauge.ring.dot), clip(circle), background(accent)]}/>
|
|
100
|
+
</Box>
|
|
101
|
+
) : null}
|
|
102
|
+
{center != null ? (
|
|
103
|
+
<Text
|
|
104
|
+
color={currentValueLabel != null ? accent : colors.onSurface}
|
|
105
|
+
style={{fontSize: gauge.ring.centerFontSize, lineHeight: gauge.ring.centerLineHeight}}>
|
|
106
|
+
{center}
|
|
107
|
+
</Text>
|
|
108
|
+
) : null}
|
|
109
|
+
{variant === 'circular' && (minText || maxText) ? (
|
|
110
|
+
<Row
|
|
111
|
+
horizontalArrangement="spaceBetween"
|
|
112
|
+
verticalAlignment="center"
|
|
113
|
+
modifiers={[offset(0, gauge.ring.boundsOffset), width(gauge.ring.boundsWidth)]}>
|
|
114
|
+
{minimumValueLabel != null ? (
|
|
115
|
+
<Text color={accent} style={{fontSize: gauge.ring.boundsFontSize, lineHeight: gauge.ring.boundsLineHeight}}>
|
|
116
|
+
{minimumValueLabel}
|
|
117
|
+
</Text>
|
|
118
|
+
) : <Spacer/>}
|
|
119
|
+
{maximumValueLabel != null ? (
|
|
120
|
+
<Text color={accent} style={{fontSize: gauge.ring.boundsFontSize, lineHeight: gauge.ring.boundsLineHeight}}>
|
|
121
|
+
{maximumValueLabel}
|
|
122
|
+
</Text>
|
|
123
|
+
) : <Spacer/>}
|
|
124
|
+
</Row>
|
|
125
|
+
) : null}
|
|
126
|
+
</Box>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (variant === 'linear') {
|
|
131
|
+
const {bar, dot, knockout: knock} = gauge.linear;
|
|
132
|
+
return (
|
|
133
|
+
<Row verticalAlignment="center" horizontalArrangement={{spacedBy: gauge.rowGap}} modifiers={[fillMaxWidth(), ...testMods]}>
|
|
134
|
+
{minText}
|
|
135
|
+
<Box contentAlignment="centerStart" modifiers={[weight(1), height(knock)]}>
|
|
136
|
+
<Box modifiers={[fillMaxWidth(), height(bar), clip(pill), background(accent)]}/>
|
|
137
|
+
<Box modifiers={[fillMaxWidth(), fillMaxHeight(), padding(dot / 2, 0, dot / 2, 0)]}>
|
|
138
|
+
<Box contentAlignment="centerEnd" modifiers={[fillMaxWidth(f), fillMaxHeight()]}>
|
|
139
|
+
<Box
|
|
140
|
+
contentAlignment="center"
|
|
141
|
+
modifiers={[offset(knock / 2, 0), size(knock, knock), clip(circle), background(knockout)]}>
|
|
142
|
+
<Box modifiers={[size(dot, dot), clip(circle), background(accent)]}/>
|
|
143
|
+
</Box>
|
|
144
|
+
</Box>
|
|
145
|
+
</Box>
|
|
146
|
+
</Box>
|
|
147
|
+
{maxText}
|
|
148
|
+
</Row>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (variant === 'linearCapacity') {
|
|
153
|
+
const {bar, gap} = gauge.linearCapacity;
|
|
154
|
+
// Invisible copy of the bounds label so the stacked label and current
|
|
155
|
+
// value start at the bar's leading edge, as the SwiftUI layout does.
|
|
156
|
+
const ghost = minimumValueLabel != null
|
|
157
|
+
? <Text style={body} modifiers={[alpha(0)]}>{minimumValueLabel}</Text>
|
|
158
|
+
: null;
|
|
159
|
+
return (
|
|
160
|
+
<Column verticalArrangement={{spacedBy: gap}} modifiers={[fillMaxWidth(), ...testMods]}>
|
|
161
|
+
{label != null ? (
|
|
162
|
+
<Row horizontalArrangement={{spacedBy: gauge.rowGap}}>
|
|
163
|
+
{ghost}
|
|
164
|
+
<Text color={colors.onSurface} style={body}>{label}</Text>
|
|
165
|
+
</Row>
|
|
166
|
+
) : null}
|
|
167
|
+
<Row verticalAlignment="center" horizontalArrangement={{spacedBy: gauge.rowGap}} modifiers={[fillMaxWidth()]}>
|
|
168
|
+
{minText}
|
|
169
|
+
<Box contentAlignment="centerStart" modifiers={[weight(1), height(bar), clip(pill), background(track.linearCapacity[scheme])]}>
|
|
170
|
+
<Box modifiers={[fillMaxWidth(f), fillMaxHeight(), clip(pill), background(accent)]}/>
|
|
171
|
+
</Box>
|
|
172
|
+
{maxText}
|
|
173
|
+
</Row>
|
|
174
|
+
{currentValueLabel != null ? (
|
|
175
|
+
<Row horizontalArrangement={{spacedBy: gauge.rowGap}}>
|
|
176
|
+
{ghost}
|
|
177
|
+
<Text
|
|
178
|
+
color={accent}
|
|
179
|
+
style={{fontSize: gauge.linearCapacity.currentFontSize, lineHeight: gauge.linearCapacity.currentLineHeight}}>
|
|
180
|
+
{currentValueLabel}
|
|
181
|
+
</Text>
|
|
182
|
+
</Row>
|
|
183
|
+
) : null}
|
|
184
|
+
</Column>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const {bar, gapAbove, gapBelow} = gauge.automatic;
|
|
189
|
+
return (
|
|
190
|
+
<Column horizontalAlignment="center" modifiers={[fillMaxWidth(), ...testMods]}>
|
|
191
|
+
{label != null ? (
|
|
192
|
+
<>
|
|
193
|
+
<Text color={colors.onSurface} style={body}>{label}</Text>
|
|
194
|
+
<Spacer modifiers={[height(gapAbove)]}/>
|
|
195
|
+
</>
|
|
196
|
+
) : null}
|
|
197
|
+
<Row verticalAlignment="center" horizontalArrangement={{spacedBy: gauge.rowGap}} modifiers={[fillMaxWidth()]}>
|
|
198
|
+
{minText}
|
|
199
|
+
<Box contentAlignment="centerStart" modifiers={[weight(1), height(bar), clip(pill), background(track.automatic[scheme])]}>
|
|
200
|
+
<Box modifiers={[fillMaxWidth(f), fillMaxHeight(), clip(pill), background(accent)]}/>
|
|
201
|
+
</Box>
|
|
202
|
+
{maxText}
|
|
203
|
+
</Row>
|
|
204
|
+
{currentValueLabel != null ? (
|
|
205
|
+
<>
|
|
206
|
+
<Spacer modifiers={[height(gapBelow)]}/>
|
|
207
|
+
<Text color={accent} style={body}>{currentValueLabel}</Text>
|
|
208
|
+
</>
|
|
209
|
+
) : null}
|
|
210
|
+
</Column>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {GaugeProps} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Gauge as SwiftUIGauge, Text} from '@expo/ui/swift-ui';
|
|
5
|
+
import {gaugeStyle, tint} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS renders SwiftUI's `Gauge` (iOS 16+) in the requested `gaugeStyle`. The
|
|
9
|
+
* Host-level `tint` cascade colors it with the accent seed; `accentColor`
|
|
10
|
+
* overrides that per instance. Each text label is a SwiftUI `Text` in the
|
|
11
|
+
* matching gauge slot, so SwiftUI picks the fonts and placement.
|
|
12
|
+
*/
|
|
13
|
+
export function Gauge({
|
|
14
|
+
value,
|
|
15
|
+
min = 0,
|
|
16
|
+
max = 1,
|
|
17
|
+
variant = 'automatic',
|
|
18
|
+
label,
|
|
19
|
+
currentValueLabel,
|
|
20
|
+
minimumValueLabel,
|
|
21
|
+
maximumValueLabel,
|
|
22
|
+
accentColor,
|
|
23
|
+
testID,
|
|
24
|
+
}: GaugeProps) {
|
|
25
|
+
const modifiers: ViewModifier[] = [gaugeStyle(variant)];
|
|
26
|
+
if (accentColor) modifiers.push(tint(accentColor));
|
|
27
|
+
return (
|
|
28
|
+
<SwiftUIGauge
|
|
29
|
+
value={value}
|
|
30
|
+
min={min}
|
|
31
|
+
max={max}
|
|
32
|
+
currentValueLabel={currentValueLabel != null ? <Text>{currentValueLabel}</Text> : undefined}
|
|
33
|
+
minimumValueLabel={minimumValueLabel != null ? <Text>{minimumValueLabel}</Text> : undefined}
|
|
34
|
+
maximumValueLabel={maximumValueLabel != null ? <Text>{maximumValueLabel}</Text> : undefined}
|
|
35
|
+
modifiers={modifiers}
|
|
36
|
+
testID={testID}>
|
|
37
|
+
{label != null ? <Text>{label}</Text> : undefined}
|
|
38
|
+
</SwiftUIGauge>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import './gauge.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {GaugeProps} from './types';
|
|
4
|
+
import {StyleSheet, type TextStyle} from 'react-native';
|
|
5
|
+
import {flatten} from '../theme';
|
|
6
|
+
import {fraction, gauge, markerOffset} from './shared';
|
|
7
|
+
|
|
8
|
+
const polar = (angle: number, radius: number, center: number) => ({
|
|
9
|
+
x: center + radius * Math.cos((angle * Math.PI) / 180),
|
|
10
|
+
y: center + radius * Math.sin((angle * Math.PI) / 180),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* On web each SwiftUI gauge style is redrawn with DOM elements (the bars)
|
|
15
|
+
* and inline SVG (the rings), sized in CSS pixels to the geometry measured
|
|
16
|
+
* from iOS. Colors flow through custom properties: the accent tints the
|
|
17
|
+
* indicator and the value labels, the descriptive label keeps the label
|
|
18
|
+
* color, and the marker knockout paints the scheme background.
|
|
19
|
+
*/
|
|
20
|
+
export function Gauge({
|
|
21
|
+
value,
|
|
22
|
+
min = 0,
|
|
23
|
+
max = 1,
|
|
24
|
+
variant = 'automatic',
|
|
25
|
+
label,
|
|
26
|
+
currentValueLabel,
|
|
27
|
+
minimumValueLabel,
|
|
28
|
+
maximumValueLabel,
|
|
29
|
+
accentColor,
|
|
30
|
+
testID,
|
|
31
|
+
style,
|
|
32
|
+
}: GaugeProps) {
|
|
33
|
+
const f = fraction(value, min, max);
|
|
34
|
+
const vars = {
|
|
35
|
+
...(accentColor ? {'--ui-gauge-accent': accentColor} : null),
|
|
36
|
+
...flatten(StyleSheet.flatten(style) as TextStyle),
|
|
37
|
+
} as CSSProperties;
|
|
38
|
+
const meter = {
|
|
39
|
+
role: 'meter',
|
|
40
|
+
'aria-label': label,
|
|
41
|
+
'aria-valuemin': min,
|
|
42
|
+
'aria-valuemax': max,
|
|
43
|
+
'aria-valuenow': Math.min(max, Math.max(min, value)),
|
|
44
|
+
'aria-valuetext': currentValueLabel,
|
|
45
|
+
} as const;
|
|
46
|
+
const bounds = {
|
|
47
|
+
min: minimumValueLabel != null ? <span className="ui-gauge__bound">{minimumValueLabel}</span> : null,
|
|
48
|
+
max: maximumValueLabel != null ? <span className="ui-gauge__bound">{maximumValueLabel}</span> : null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (variant === 'circular' || variant === 'circularCapacity') {
|
|
52
|
+
const {size, stroke, arcStart, arcSweep, dot, knockout} = gauge.ring;
|
|
53
|
+
const center = size / 2;
|
|
54
|
+
const radius = (size - stroke) / 2;
|
|
55
|
+
const start = polar(arcStart, radius, center);
|
|
56
|
+
const end = polar(arcStart + arcSweep, radius, center);
|
|
57
|
+
const marker = markerOffset(f);
|
|
58
|
+
const circumference = 2 * Math.PI * radius;
|
|
59
|
+
const text = currentValueLabel ?? label;
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
{...meter}
|
|
63
|
+
className={`ui-gauge-ring ui-gauge-ring--${variant}`}
|
|
64
|
+
style={{...vars, width: size, height: size}}
|
|
65
|
+
data-testid={testID}>
|
|
66
|
+
<svg className="ui-gauge-ring__svg" width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
|
|
67
|
+
{variant === 'circular' ? (
|
|
68
|
+
<>
|
|
69
|
+
<path
|
|
70
|
+
className="ui-gauge-ring__arc"
|
|
71
|
+
// The 240° sweep is always the large arc, drawn clockwise.
|
|
72
|
+
d={`M ${start.x} ${start.y} A ${radius} ${radius} 0 1 1 ${end.x} ${end.y}`}
|
|
73
|
+
fill="none"
|
|
74
|
+
strokeWidth={stroke}
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
/>
|
|
77
|
+
<circle className="ui-gauge-ring__knockout" cx={center + marker.x} cy={center + marker.y} r={knockout / 2}/>
|
|
78
|
+
<circle className="ui-gauge-ring__dot" cx={center + marker.x} cy={center + marker.y} r={dot / 2}/>
|
|
79
|
+
</>
|
|
80
|
+
) : (
|
|
81
|
+
<>
|
|
82
|
+
<circle className="ui-gauge-ring__track" cx={center} cy={center} r={radius} fill="none" strokeWidth={stroke}/>
|
|
83
|
+
{f > 0 ? (
|
|
84
|
+
<circle
|
|
85
|
+
className="ui-gauge-ring__fill"
|
|
86
|
+
cx={center}
|
|
87
|
+
cy={center}
|
|
88
|
+
r={radius}
|
|
89
|
+
fill="none"
|
|
90
|
+
strokeWidth={stroke}
|
|
91
|
+
strokeLinecap="round"
|
|
92
|
+
strokeDasharray={`${f * circumference} ${circumference}`}
|
|
93
|
+
transform={`rotate(-90 ${center} ${center})`}
|
|
94
|
+
/>
|
|
95
|
+
) : null}
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</svg>
|
|
99
|
+
{text != null ? (
|
|
100
|
+
<span className={['ui-gauge-ring__center', currentValueLabel == null && 'ui-gauge-ring__center--label'].filter(Boolean).join(' ')}>
|
|
101
|
+
{text}
|
|
102
|
+
</span>
|
|
103
|
+
) : null}
|
|
104
|
+
{variant === 'circular' && (bounds.min || bounds.max) ? (
|
|
105
|
+
<span className="ui-gauge-ring__bounds">
|
|
106
|
+
{bounds.min ?? <span/>}
|
|
107
|
+
{bounds.max ?? <span/>}
|
|
108
|
+
</span>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (variant === 'linear') {
|
|
115
|
+
return (
|
|
116
|
+
<div {...meter} className="ui-gauge ui-gauge--linear" style={vars} data-testid={testID}>
|
|
117
|
+
{bounds.min}
|
|
118
|
+
<span className="ui-gauge__track">
|
|
119
|
+
<span className="ui-gauge__marker" style={{left: `calc(${gauge.linear.dot / 2}px + ${f} * (100% - ${gauge.linear.dot}px))`}}>
|
|
120
|
+
<span className="ui-gauge__dot"/>
|
|
121
|
+
</span>
|
|
122
|
+
</span>
|
|
123
|
+
{bounds.max}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fill = <span className="ui-gauge__fill" style={{width: `${f * 100}%`}}/>;
|
|
129
|
+
|
|
130
|
+
if (variant === 'linearCapacity') {
|
|
131
|
+
// A grid whose columns exist only for the bounds that are set, so the
|
|
132
|
+
// stacked label and current value start exactly at the bar's leading edge.
|
|
133
|
+
const columns = [bounds.min && 'auto', 'minmax(0, 1fr)', bounds.max && 'auto'].filter(Boolean).join(' ');
|
|
134
|
+
const barColumn = bounds.min ? 2 : 1;
|
|
135
|
+
const barRow = label != null ? 2 : 1;
|
|
136
|
+
const stacked = {gridColumn: barColumn} as const;
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
{...meter}
|
|
140
|
+
className="ui-gauge ui-gauge--linear-capacity"
|
|
141
|
+
style={{...vars, gridTemplateColumns: columns}}
|
|
142
|
+
data-testid={testID}>
|
|
143
|
+
{label != null ? <span className="ui-gauge__label" style={{...stacked, gridRow: 1}}>{label}</span> : null}
|
|
144
|
+
{bounds.min ? <span className="ui-gauge__bound" style={{gridColumn: 1, gridRow: barRow}}>{minimumValueLabel}</span> : null}
|
|
145
|
+
<span className="ui-gauge__track" style={{...stacked, gridRow: barRow}}>{fill}</span>
|
|
146
|
+
{bounds.max ? <span className="ui-gauge__bound" style={{gridColumn: barColumn + 1, gridRow: barRow}}>{maximumValueLabel}</span> : null}
|
|
147
|
+
{currentValueLabel != null ? (
|
|
148
|
+
<span className="ui-gauge__current" style={{...stacked, gridRow: barRow + 1}}>{currentValueLabel}</span>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div {...meter} className="ui-gauge ui-gauge--automatic" style={vars} data-testid={testID}>
|
|
156
|
+
{label != null ? <span className="ui-gauge__label">{label}</span> : null}
|
|
157
|
+
<span className="ui-gauge__row">
|
|
158
|
+
{bounds.min}
|
|
159
|
+
<span className="ui-gauge__track">{fill}</span>
|
|
160
|
+
{bounds.max}
|
|
161
|
+
</span>
|
|
162
|
+
{currentValueLabel != null ? <span className="ui-gauge__current">{currentValueLabel}</span> : null}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry of the SwiftUI gauge styles, in points, measured from iOS
|
|
3
|
+
* screenshots so the Android and web redraws match the native control.
|
|
4
|
+
*/
|
|
5
|
+
export const gauge = {
|
|
6
|
+
/** Body text of the labels (the SwiftUI default font). */
|
|
7
|
+
fontSize: 17,
|
|
8
|
+
lineHeight: 22,
|
|
9
|
+
/** Space between a bounds label and the bar. */
|
|
10
|
+
rowGap: 8,
|
|
11
|
+
|
|
12
|
+
/** `automatic`: capacity bar height and the gaps to the labels above/below. */
|
|
13
|
+
automatic: {bar: 15, gapAbove: 14, gapBelow: 9},
|
|
14
|
+
|
|
15
|
+
/** `linear`: bar height, marker dot (same size) and its background knockout. */
|
|
16
|
+
linear: {bar: 7.5, dot: 7.5, knockout: 15},
|
|
17
|
+
|
|
18
|
+
/** `linearCapacity`: bar height, stack spacing and the current value font. */
|
|
19
|
+
linearCapacity: {bar: 4, gap: 4, currentFontSize: 12, currentLineHeight: 16},
|
|
20
|
+
|
|
21
|
+
/** Circular styles: ring diameter/stroke, arc, marker and label metrics. */
|
|
22
|
+
ring: {
|
|
23
|
+
size: 58,
|
|
24
|
+
stroke: 5.5,
|
|
25
|
+
/** Open ring (`circular`): arc start angle (degrees, clockwise from 3 o'clock) and sweep. */
|
|
26
|
+
arcStart: 150,
|
|
27
|
+
arcSweep: 240,
|
|
28
|
+
dot: 5.5,
|
|
29
|
+
knockout: 10,
|
|
30
|
+
centerFontSize: 24,
|
|
31
|
+
centerLineHeight: 29,
|
|
32
|
+
boundsFontSize: 10,
|
|
33
|
+
boundsLineHeight: 12,
|
|
34
|
+
/**
|
|
35
|
+
* Width of the bounds row and the offset of its center below the ring
|
|
36
|
+
* center: clear of the 24pt value above it and inside the arc's gap.
|
|
37
|
+
*/
|
|
38
|
+
boundsWidth: 34,
|
|
39
|
+
boundsOffset: 17,
|
|
40
|
+
/** Opacity of the unfilled track in `circularCapacity`. */
|
|
41
|
+
trackOpacity: 0.3,
|
|
42
|
+
},
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Unfilled track fills, as `#RRGGBBAA` per scheme: iOS `tertiarySystemFill`
|
|
47
|
+
* behind the `automatic` bar and `systemFill` behind the `linearCapacity` bar.
|
|
48
|
+
*/
|
|
49
|
+
export const track = {
|
|
50
|
+
automatic: {light: '#7676801F', dark: '#7676803D'},
|
|
51
|
+
linearCapacity: {light: '#78788033', dark: '#7878805C'},
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
/** Position of `value` within `[min, max]`, clamped to `0…1`. */
|
|
55
|
+
export function fraction(value: number, min: number, max: number): number {
|
|
56
|
+
if (max <= min) return 0;
|
|
57
|
+
return Math.min(1, Math.max(0, (value - min) / (max - min)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Center of the `circular` marker for a fraction, relative to the ring center
|
|
62
|
+
* (y grows downwards), on the stroke's center line.
|
|
63
|
+
*/
|
|
64
|
+
export function markerOffset(f: number): {x: number; y: number} {
|
|
65
|
+
const angle = ((gauge.ring.arcStart + f * gauge.ring.arcSweep) * Math.PI) / 180;
|
|
66
|
+
const radius = (gauge.ring.size - gauge.ring.stroke) / 2;
|
|
67
|
+
return {x: radius * Math.cos(angle), y: radius * Math.sin(angle)};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `#RRGGBB` (or `#RGB`/`#RRGGBBAA`) color with its alpha replaced. Other
|
|
72
|
+
* color syntaxes are returned unchanged.
|
|
73
|
+
*/
|
|
74
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
75
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(color.trim())?.[1];
|
|
76
|
+
if (!hex) return color;
|
|
77
|
+
const rgb = hex.length === 3 ? hex.split('').map(c => c + c).join('') : hex.slice(0, 6);
|
|
78
|
+
return `#${rgb}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`.toUpperCase();
|
|
79
|
+
}
|