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.
Files changed (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. 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
+ }