expo-interface 0.1.1 → 0.2.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.
@@ -7,12 +7,28 @@
7
7
  }
8
8
 
9
9
  /*
10
- * @expo/ui hardcodes inverted iOS grouped-list colors on web (#f2f2f7 page,
11
- * #ffffff cards). Light mode should match native: white page, grey cards.
12
- * Target only section cards not nested pills/controls inside rows.
10
+ * @expo/ui paints inverted iOS grouped-list colors on web (grey page, white
11
+ * cards in light mode). Match native instead: the page is the screen
12
+ * background and the cards the raised element color, in both schemes, so the
13
+ * cards follow the kit palette rather than @expo/ui's own (which switches on
14
+ * `prefers-color-scheme` and can disagree with a forced scheme).
15
+ *
16
+ * The section card reads `--expo-ui-background`, so remap that variable here
17
+ * rather than matching react-native-web class names: those carry the property
18
+ * name (`r-borderRadius-…`) only in development and are hashed to `r-…` in a
19
+ * production build. Nothing else the kit renders inside a group reads it.
13
20
  */
14
- @media (prefers-color-scheme: light) {
15
- .field-group [class*='r-borderRadius-']:not([class*='r-borderRadius-'] [class*='r-borderRadius-']) {
16
- background-color: var(--color-background-element) !important;
17
- }
21
+ .field-group {
22
+ --expo-ui-background: var(--color-background-element);
23
+ }
24
+
25
+ /*
26
+ * Row dividers: @expo/ui paints them in its gray-100, which is within a few
27
+ * shades of the card color above in both schemes. The selected-background
28
+ * token is one step away from the card in either scheme: visible, not heavy.
29
+ * `role="separator"` is the only stable hook; kit overlays inside a row (a
30
+ * Menu's own separator) carry `ui-*` classes and keep their color.
31
+ */
32
+ .field-group [role='separator']:not([class^='ui-']) {
33
+ background-color: var(--color-background-selected);
18
34
  }
@@ -59,9 +59,10 @@ function SectionFooter(props: FieldSectionFooterProps) {
59
59
  function Section({children, title, titleUppercase = false, hidden}: FieldSectionProps) {
60
60
  const card = useColor('backgroundElement');
61
61
  const subtle = useColor('secondaryLabel');
62
+ const label = useColor('label');
62
63
  if (hidden) return null;
63
64
 
64
- const {header, footer, rows} = extractSlots(children);
65
+ const {header, footer, rows} = extractSlots(children, label);
65
66
  const headerNode = header ?? (title ? (
66
67
  <Text
67
68
  color={subtle}
@@ -118,33 +119,33 @@ function cornerRadii(index: number, total: number) {
118
119
  return {topStart: top, topEnd: top, bottomStart: bottom, bottomEnd: bottom};
119
120
  }
120
121
 
121
- /** Pulls `SectionHeader`/`SectionFooter` slots out of a section's children. */
122
- function extractSlots(children: ReactNode) {
122
+ /**
123
+ * Pulls `SectionHeader`/`SectionFooter` slots out of a section's children.
124
+ * Empty children (`null`, booleans) are dropped, and raw strings or numbers
125
+ * become a `Text` row, since Compose slots cannot render them directly.
126
+ */
127
+ function extractSlots(children: ReactNode, textColor: string) {
123
128
  let header: ReactNode | undefined;
124
129
  let footer: ReactNode | undefined;
125
130
  const rows: ReactNode[] = [];
126
131
 
127
132
  const walk = (node: ReactNode) => {
128
- Children.forEach(node, child => {
133
+ for (const child of Children.toArray(node)) {
129
134
  if (!isValidElement(child)) {
130
- rows.push(child);
131
- return;
135
+ rows.push(<Text color={textColor}>{String(child)}</Text>);
136
+ continue;
132
137
  }
133
138
  const props = child.props as {children?: ReactNode};
134
139
  if (child.type === SectionHeader) {
135
140
  header = props.children;
136
- return;
137
- }
138
- if (child.type === SectionFooter) {
141
+ } else if (child.type === SectionFooter) {
139
142
  footer = props.children;
140
- return;
141
- }
142
- if (child.type === Fragment) {
143
+ } else if (child.type === Fragment) {
143
144
  walk(props.children);
144
- return;
145
+ } else {
146
+ rows.push(child);
145
147
  }
146
- rows.push(child);
147
- });
148
+ }
148
149
  };
149
150
 
150
151
  walk(children);
@@ -175,13 +176,12 @@ function groupChildren(children: ReactNode): ReactNode[] {
175
176
  return;
176
177
  }
177
178
  if (isValidElement(child) && child.type === Fragment) {
178
- for (const nested of groupChildren((child.props as {children?: ReactNode}).children)) {
179
- if (isSection(nested)) {
180
- flush();
181
- result.push(nested);
182
- } else {
183
- buffered.push(nested);
184
- }
179
+ // The recursion yields sections only (loose rows come back wrapped in
180
+ // their own implicit section), so they close any open implicit section.
181
+ const nested = groupChildren((child.props as {children?: ReactNode}).children);
182
+ if (nested.length > 0) {
183
+ flush();
184
+ result.push(...nested);
185
185
  }
186
186
  return;
187
187
  }
@@ -3,8 +3,8 @@ import {FieldGroup as BaseFieldGroup, type FieldGroupProps} from '@expo/ui';
3
3
 
4
4
  /**
5
5
  * Web hook for `field-group.css`. Clears the universal component's hardcoded
6
- * scroll background so the wrapper supplies the app palette; section cards are
7
- * recolored in CSS (light mode only dark matches @expo/ui).
6
+ * scroll background so the wrapper supplies the app palette; section cards
7
+ * and row dividers are recolored to the kit tokens in the CSS.
8
8
  */
9
9
  function FieldGroup({style, ...props}: FieldGroupProps) {
10
10
  return (
@@ -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
+ }