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.
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import type {StyleProp, ViewStyle} from 'react-native';
2
+
3
+ /**
4
+ * Visual style of the gauge, named after the SwiftUI `GaugeStyle` it renders
5
+ * (the `gaugeStyle` modifier names in `@expo/ui`):
6
+ * - `automatic` — the default in-app style: a capacity bar with the label
7
+ * centered above and the current value centered below.
8
+ * - `linear` — `accessoryLinear`: a bar with a point marker at the current
9
+ * value (no label).
10
+ * - `linearCapacity` — `accessoryLinearCapacity`: a thin capacity bar with
11
+ * the label above and the current value below, aligned to its leading edge.
12
+ * - `circular` — `accessoryCircular`: an open ring with a point marker, the
13
+ * current value in the center and the bounds under the ring.
14
+ * - `circularCapacity` — `accessoryCircularCapacity`: a closed ring filled to
15
+ * the current value with the current value in the center.
16
+ */
17
+ export type GaugeVariant = 'automatic' | 'linear' | 'linearCapacity' | 'circular' | 'circularCapacity';
18
+
19
+ /**
20
+ * Cross-platform gauge showing a value within a range.
21
+ *
22
+ * Bridges the SwiftUI `Gauge` (iOS 16+) on iOS; Android (Jetpack Compose) and
23
+ * web (DOM/SVG) redraw each SwiftUI style with the same geometry: the
24
+ * bounds and current value labels take the accent, the descriptive label
25
+ * keeps the label color, exactly as a tinted SwiftUI gauge does.
26
+ */
27
+ export interface GaugeProps {
28
+ /** Current value, between `min` and `max`. */
29
+ value: number;
30
+ /**
31
+ * Lower bound of the range.
32
+ * @default 0
33
+ */
34
+ min?: number;
35
+ /**
36
+ * Upper bound of the range.
37
+ * @default 1
38
+ */
39
+ max?: number;
40
+ /**
41
+ * SwiftUI gauge style to render.
42
+ * @default 'automatic'
43
+ */
44
+ variant?: GaugeVariant;
45
+ /**
46
+ * Text describing the gauge's purpose. Shown above the bar in the linear
47
+ * capacity styles and in the center of the circular styles when there is
48
+ * no `currentValueLabel`; the `linear` style only exposes it to assistive
49
+ * technology.
50
+ */
51
+ label?: string;
52
+ /** Text showing the current value. */
53
+ currentValueLabel?: string;
54
+ /** Text showing the lower bound. */
55
+ minimumValueLabel?: string;
56
+ /** Text showing the upper bound. */
57
+ maximumValueLabel?: string;
58
+ /** Tint of the indicator and the value labels (overrides the theme accent). */
59
+ accentColor?: string;
60
+ /** Identifier used to locate the component in end-to-end tests. */
61
+ testID?: string;
62
+ /** Style applied to the container (web only). */
63
+ style?: StyleProp<ViewStyle>;
64
+ }
package/src/index.ts CHANGED
@@ -24,12 +24,16 @@ export {Checkbox} from './checkbox';
24
24
  export type {CheckboxProps} from './checkbox/types';
25
25
  export {Collapsible} from './collapsible';
26
26
  export type {CollapsibleProps} from './collapsible/types';
27
+ export {ColorPicker} from './color-picker';
28
+ export type {ColorPickerProps} from './color-picker/types';
27
29
  export {ContextMenu} from './context-menu';
28
30
  export {DateTimePicker} from './date-time';
29
31
  export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
30
32
  export {Divider} from './divider';
31
33
  export type {DividerProps} from './divider/types';
32
34
  export {FieldGroup, type FieldGroupProps} from './field-group';
35
+ export {Gauge} from './gauge';
36
+ export type {GaugeProps, GaugeVariant} from './gauge/types';
33
37
  export {ListItem} from './list-item';
34
38
  export type {ListItemProps} from './list-item/types';
35
39
  export {Menu} from './menu';
@@ -38,7 +42,6 @@ export {Picker} from './picker';
38
42
  export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
39
43
  export {Progress} from './progress';
40
44
  export type {ProgressProps, ProgressVariant} from './progress/types';
41
- export {QRCode, type QRCodeProps} from './qr';
42
45
  export {SegmentedControl} from './segmented';
43
46
  export type {SegmentedControlProps} from './segmented/types';
44
47
  export {Slider} from './slider';
@@ -8,6 +8,15 @@
8
8
  -webkit-appearance: none;
9
9
  }
10
10
 
11
+ /*
12
+ * With `appearance: none` Chromium lays the bar out as an inline-block on a
13
+ * line box, so the value only paints about half the track height. Flex
14
+ * stretches the bar (and its value) to the full 6px.
15
+ */
16
+ .ui-progress::-webkit-meter-inner-element {
17
+ display: flex;
18
+ }
19
+
11
20
  .ui-progress::-webkit-meter-bar {
12
21
  border: none;
13
22
  border-radius: 999px;
@@ -21,6 +21,7 @@ export function ScreenHeader({title, onBack, trailing}: ScreenHeaderProps) {
21
21
  {onBack ? (
22
22
  <Pressable
23
23
  onPress={onBack}
24
+ role="button"
24
25
  accessibilityLabel="Go back"
25
26
  style={styles.back}>
26
27
  <SymbolView
@@ -24,7 +24,7 @@ function SegmentedControlComponent<T extends PickerValue>({
24
24
  }: SegmentedControlProps<T>) {
25
25
  const items = extractItems<T>(children);
26
26
  const [current, setValue] = useSelectedValue(selectedValue, onValueChange, items[0]?.value);
27
- const vars = flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined) as CSSProperties;
27
+ const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
28
28
  return (
29
29
  <div
30
30
  className={['ui-segmented', disabled && 'ui-segmented--disabled'].filter(Boolean).join(' ')}
@@ -35,18 +35,17 @@
35
35
  transition: background-color 0.15s ease, box-shadow 0.15s ease;
36
36
  }
37
37
 
38
+ /*
39
+ * Raised segment (iOS: white light / systemGray2-ish dark). `light-dark()`
40
+ * follows the element's `color-scheme`, which the theme CSS sets from the OS
41
+ * and which a host can force (Storybook's scheme toolbar does).
42
+ */
38
43
  .ui-segmented__item[aria-checked='true'] {
39
- background: #ffffff;
44
+ background: light-dark(#ffffff, #636366);
40
45
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
41
46
  font-weight: 600;
42
47
  }
43
48
 
44
- @media (prefers-color-scheme: dark) {
45
- .ui-segmented__item[aria-checked='true'] {
46
- background: #636366;
47
- }
48
- }
49
-
50
49
  .ui-segmented__item:disabled {
51
50
  cursor: default;
52
51
  }
@@ -26,7 +26,7 @@ export function Slider({
26
26
  }: SliderProps) {
27
27
  const vars = {
28
28
  ...(accentColor ? {'--ui-slider-accent': accentColor} : null),
29
- ...flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined),
29
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
30
30
  } as CSSProperties;
31
31
  const read = (event: ChangeEvent<HTMLInputElement>) => Number(event.target.value);
32
32
  return (
@@ -14,7 +14,7 @@ import {clampStep, stepBounds} from './shared';
14
14
  export function Stepper(props: StepperProps) {
15
15
  const {label, value, onValueChange, step = 1, min, max, formatValue, disabled, testID, style} = props;
16
16
  const {canDecrement, canIncrement} = stepBounds(props);
17
- const vars = flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined) as CSSProperties;
17
+ const vars = flatten(StyleSheet.flatten(style) as TextStyle) as CSSProperties;
18
18
  return (
19
19
  <div
20
20
  className={['ui-stepper', disabled && 'ui-stepper--disabled'].filter(Boolean).join(' ')}
@@ -28,6 +28,8 @@ export function Switch({
28
28
  value={value}
29
29
  onValueChange={onValueChange}
30
30
  disabled={disabled}
31
+ // The label is a sibling, not a <label>, so name the input directly.
32
+ accessibilityLabel={label}
31
33
  thumbColor={THUMB}
32
34
  trackColor={{true: onColor, false: offColor}}
33
35
  testID={label == null ? testID : undefined}
package/src/qr/index.tsx DELETED
@@ -1,26 +0,0 @@
1
- import {useMemo} from 'react';
2
- import {Image} from 'expo-image';
3
- import createQR from 'qrcode-generator';
4
-
5
- export interface QRCodeProps {
6
- /** Value encoded in the QR code. */
7
- value: string;
8
- /** Rendered width/height in points. */
9
- size?: number;
10
- }
11
-
12
- export function QRCode({value, size = 200}: QRCodeProps) {
13
- const uri = useMemo(() => {
14
- const qr = createQR(0, 'M');
15
- qr.addData(value);
16
- qr.make();
17
- return qr.createDataURL(8, 2);
18
- }, [value]);
19
- return (
20
- <Image
21
- source={{uri}}
22
- style={{width: size, height: size, borderRadius: 12}}
23
- contentFit="contain"
24
- />
25
- );
26
- }