expo-interface 0.1.0 → 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.
- package/README.md +282 -180
- package/package.json +39 -13
- package/src/alert/alert.css +49 -0
- package/src/alert/index.android.tsx +68 -0
- package/src/alert/index.ios.tsx +47 -0
- package/src/alert/index.tsx +60 -0
- package/src/alert/shared.ts +10 -0
- package/src/alert/types.ts +58 -0
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +9 -2
- package/src/button/index.ios.tsx +8 -3
- package/src/button/index.tsx +19 -1
- package/src/button/types.ts +7 -0
- package/src/checkbox/checkbox.css +29 -0
- package/src/checkbox/index.android.tsx +54 -0
- package/src/checkbox/index.ios.tsx +50 -0
- package/src/checkbox/index.tsx +50 -0
- package/src/checkbox/types.ts +27 -0
- package/src/collapsible/collapsible.css +32 -0
- package/src/collapsible/index.android.tsx +34 -0
- package/src/collapsible/index.ios.tsx +31 -0
- package/src/collapsible/index.tsx +41 -0
- package/src/collapsible/shared.ts +21 -0
- package/src/collapsible/types.ts +24 -0
- package/src/color-picker/color-picker.css +66 -0
- package/src/color-picker/index.android.tsx +117 -0
- package/src/color-picker/index.ios.tsx +33 -0
- package/src/color-picker/index.tsx +62 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +34 -0
- package/src/context-menu/index.android.tsx +27 -0
- package/src/context-menu/index.ios.tsx +22 -0
- package/src/context-menu/index.tsx +56 -0
- package/src/date-time/index.android.tsx +3 -2
- package/src/divider/divider.css +16 -0
- package/src/divider/index.android.tsx +26 -0
- package/src/divider/index.ios.tsx +18 -0
- package/src/divider/index.tsx +21 -0
- package/src/divider/types.ts +20 -0
- package/src/field-group/field-group.css +23 -7
- package/src/field-group/index.android.tsx +22 -22
- package/src/field-group/index.web.tsx +2 -2
- 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/index.ts +24 -3
- package/src/menu/index.android.tsx +66 -0
- package/src/menu/index.ios.tsx +72 -0
- package/src/menu/index.tsx +30 -0
- package/src/menu/list.tsx +98 -0
- package/src/menu/menu.css +83 -0
- package/src/menu/types.ts +60 -0
- package/src/picker/index.android.tsx +3 -2
- package/src/progress/index.android.tsx +23 -9
- package/src/progress/index.ios.tsx +7 -6
- package/src/progress/index.tsx +43 -6
- package/src/progress/progress.css +33 -0
- package/src/progress/types.ts +23 -6
- package/src/screen/header.tsx +1 -0
- package/src/segmented/index.android.tsx +67 -0
- package/src/segmented/index.ios.tsx +47 -0
- package/src/segmented/index.tsx +58 -0
- package/src/segmented/segmented.css +55 -0
- package/src/segmented/types.ts +31 -0
- package/src/slider/index.android.tsx +87 -0
- package/src/slider/index.ios.tsx +64 -0
- package/src/slider/index.tsx +55 -0
- package/src/slider/slider.css +30 -0
- package/src/slider/types.ts +39 -0
- package/src/stepper/index.android.tsx +58 -0
- package/src/stepper/index.ios.tsx +55 -0
- package/src/stepper/index.tsx +47 -0
- package/src/stepper/shared.ts +17 -0
- package/src/stepper/stepper.css +61 -0
- package/src/stepper/types.ts +35 -0
- package/src/switch/index.tsx +2 -0
- package/src/text-field/shared.ts +2 -0
- package/src/tooltip/index.android.tsx +23 -0
- package/src/tooltip/index.ios.tsx +17 -0
- package/src/tooltip/index.tsx +41 -0
- package/src/tooltip/tooltip.css +40 -0
- package/src/tooltip/types.ts +25 -0
- package/src/link.ts +0 -36
- package/src/qr/index.tsx +0 -26
|
@@ -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
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
export * from './theme';
|
|
4
4
|
export * from './accent';
|
|
5
5
|
export * from './icons';
|
|
6
|
-
export * from './link';
|
|
7
6
|
export {fillWidth} from './fill';
|
|
8
7
|
|
|
9
8
|
// Layout
|
|
@@ -17,20 +16,42 @@ export {Tabs} from './tabs';
|
|
|
17
16
|
export type {TabBarProps, TabRoute, WebLogo} from './tabs/types';
|
|
18
17
|
|
|
19
18
|
// Components
|
|
19
|
+
export {Alert} from './alert';
|
|
20
|
+
export type {AlertAction, AlertActionRole, AlertProps} from './alert/types';
|
|
20
21
|
export {Button} from './button';
|
|
21
22
|
export type {ButtonProps, ButtonRole, ButtonShape, ButtonSize, ButtonVariant} from './button/types';
|
|
23
|
+
export {Checkbox} from './checkbox';
|
|
24
|
+
export type {CheckboxProps} from './checkbox/types';
|
|
25
|
+
export {Collapsible} from './collapsible';
|
|
26
|
+
export type {CollapsibleProps} from './collapsible/types';
|
|
27
|
+
export {ColorPicker} from './color-picker';
|
|
28
|
+
export type {ColorPickerProps} from './color-picker/types';
|
|
29
|
+
export {ContextMenu} from './context-menu';
|
|
22
30
|
export {DateTimePicker} from './date-time';
|
|
23
31
|
export type {DateTimeMode, DateTimePickerProps} from './date-time/types';
|
|
32
|
+
export {Divider} from './divider';
|
|
33
|
+
export type {DividerProps} from './divider/types';
|
|
24
34
|
export {FieldGroup, type FieldGroupProps} from './field-group';
|
|
35
|
+
export {Gauge} from './gauge';
|
|
36
|
+
export type {GaugeProps, GaugeVariant} from './gauge/types';
|
|
25
37
|
export {ListItem} from './list-item';
|
|
26
38
|
export type {ListItemProps} from './list-item/types';
|
|
39
|
+
export {Menu} from './menu';
|
|
40
|
+
export type {ContextMenuProps, MenuItem, MenuProps} from './menu/types';
|
|
27
41
|
export {Picker} from './picker';
|
|
28
42
|
export type {PickerItemProps, PickerOption, PickerProps, PickerValue} from './picker/types';
|
|
29
43
|
export {Progress} from './progress';
|
|
30
|
-
export type {ProgressProps} from './progress/types';
|
|
31
|
-
export {
|
|
44
|
+
export type {ProgressProps, ProgressVariant} from './progress/types';
|
|
45
|
+
export {SegmentedControl} from './segmented';
|
|
46
|
+
export type {SegmentedControlProps} from './segmented/types';
|
|
47
|
+
export {Slider} from './slider';
|
|
48
|
+
export type {SliderProps} from './slider/types';
|
|
49
|
+
export {Stepper} from './stepper';
|
|
50
|
+
export type {StepperProps} from './stepper/types';
|
|
32
51
|
export {Switch} from './switch';
|
|
33
52
|
export type {SwitchProps} from './switch/types';
|
|
53
|
+
export {Tooltip} from './tooltip';
|
|
54
|
+
export type {TooltipProps} from './tooltip/types';
|
|
34
55
|
export {TextField} from './text-field';
|
|
35
56
|
export type {TextFieldCapitalize, TextFieldKeyboard, TextFieldProps} from './text-field/types';
|
|
36
57
|
export {ExternalLink} from './router/external-link';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type {MenuItem, MenuProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {Fragment, useState} from 'react';
|
|
4
|
+
import {DropdownMenu, DropdownMenuItem, HorizontalDivider, Icon, Text, useMaterialColors} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {Button} from '../button';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
|
|
8
|
+
const ICON_SIZE = 20;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Android anchors a Material 3 `DropdownMenu` to the kit's `Button`. Entries
|
|
12
|
+
* are `DropdownMenuItem`s with an optional drawable leading icon; destructive
|
|
13
|
+
* items use the theme danger color.
|
|
14
|
+
*/
|
|
15
|
+
export function Menu({label, icon, items, testID, ...button}: MenuProps) {
|
|
16
|
+
const [expanded, setExpanded] = useState(false);
|
|
17
|
+
return (
|
|
18
|
+
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
19
|
+
<DropdownMenu.Trigger>
|
|
20
|
+
<Button
|
|
21
|
+
{...button}
|
|
22
|
+
label={label}
|
|
23
|
+
prefixIcon={icon}
|
|
24
|
+
onPress={() => setExpanded(true)}
|
|
25
|
+
testID={testID}
|
|
26
|
+
/>
|
|
27
|
+
</DropdownMenu.Trigger>
|
|
28
|
+
<MenuItems items={items} onClose={() => setExpanded(false)}/>
|
|
29
|
+
</DropdownMenu>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Compose `DropdownMenu.Items` shared by `Menu` and `ContextMenu`. */
|
|
34
|
+
export function MenuItems({items, onClose}: {items: MenuItem[]; onClose: () => void}) {
|
|
35
|
+
const colors = useMaterialColors();
|
|
36
|
+
const destructive = useColor('destructive');
|
|
37
|
+
const separator = useColor('separator');
|
|
38
|
+
return (
|
|
39
|
+
<DropdownMenu.Items>
|
|
40
|
+
{items.map((item, index) => {
|
|
41
|
+
const color = item.role === 'destructive' ? destructive : colors.onSurface;
|
|
42
|
+
return (
|
|
43
|
+
<Fragment key={index}>
|
|
44
|
+
{item.separator && index > 0 ? <HorizontalDivider color={separator}/> : null}
|
|
45
|
+
<DropdownMenuItem
|
|
46
|
+
enabled={!item.disabled}
|
|
47
|
+
elementColors={{textColor: color, leadingIconColor: color}}
|
|
48
|
+
onClick={item.disabled ? undefined : () => {
|
|
49
|
+
onClose();
|
|
50
|
+
item.onPress?.();
|
|
51
|
+
}}>
|
|
52
|
+
{item.icon?.drawable ? (
|
|
53
|
+
<DropdownMenuItem.LeadingIcon>
|
|
54
|
+
<Icon source={item.icon.drawable} size={ICON_SIZE} tint={item.disabled ? colors.onSurfaceVariant : color}/>
|
|
55
|
+
</DropdownMenuItem.LeadingIcon>
|
|
56
|
+
) : null}
|
|
57
|
+
<DropdownMenuItem.Text>
|
|
58
|
+
<Text color={item.disabled ? colors.onSurfaceVariant : color}>{item.label}</Text>
|
|
59
|
+
</DropdownMenuItem.Text>
|
|
60
|
+
</DropdownMenuItem>
|
|
61
|
+
</Fragment>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</DropdownMenu.Items>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {MenuItem, MenuProps} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Fragment} from 'react';
|
|
5
|
+
import {Button, Divider, Menu as SwiftUIMenu} from '@expo/ui/swift-ui';
|
|
6
|
+
import {buttonBorderShape, buttonStyle, controlSize, disabled as disabledMod, labelStyle, tint} from '@expo/ui/swift-ui/modifiers';
|
|
7
|
+
import {iosSymbol, swiftBorderShape, swiftControlSize} from '../button/shared';
|
|
8
|
+
import {useColor} from '../theme';
|
|
9
|
+
|
|
10
|
+
const VARIANT_STYLE = {
|
|
11
|
+
filled: 'borderedProminent',
|
|
12
|
+
outlined: 'bordered',
|
|
13
|
+
text: 'plain',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* iOS renders SwiftUI's `Menu`, styled with the same `buttonStyle` / `tint`
|
|
18
|
+
* mapping as the kit's `Button` so the trigger matches. Entries are SwiftUI
|
|
19
|
+
* `Button`s (with SF Symbol and `destructive` role) and `Divider`s.
|
|
20
|
+
*/
|
|
21
|
+
export function Menu({
|
|
22
|
+
label,
|
|
23
|
+
icon,
|
|
24
|
+
items,
|
|
25
|
+
variant = 'filled',
|
|
26
|
+
size = 'medium',
|
|
27
|
+
shape,
|
|
28
|
+
color,
|
|
29
|
+
hideLabel,
|
|
30
|
+
disabled,
|
|
31
|
+
testID,
|
|
32
|
+
}: MenuProps) {
|
|
33
|
+
const themeTint = useColor('tint');
|
|
34
|
+
const modifiers: ViewModifier[] = [
|
|
35
|
+
buttonStyle(VARIANT_STYLE[variant]),
|
|
36
|
+
controlSize(swiftControlSize(size)),
|
|
37
|
+
tint(color ?? themeTint),
|
|
38
|
+
];
|
|
39
|
+
if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
|
|
40
|
+
if (hideLabel && icon) modifiers.push(labelStyle('iconOnly'));
|
|
41
|
+
if (disabled) modifiers.push(disabledMod(true));
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<SwiftUIMenu
|
|
45
|
+
label={label}
|
|
46
|
+
systemImage={icon ? iosSymbol(icon) : undefined}
|
|
47
|
+
modifiers={modifiers}
|
|
48
|
+
testID={testID}>
|
|
49
|
+
<MenuItems items={items}/>
|
|
50
|
+
</SwiftUIMenu>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** SwiftUI menu entries shared by `Menu` and `ContextMenu`. */
|
|
55
|
+
export function MenuItems({items}: {items: MenuItem[]}) {
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
{items.map((item, index) => (
|
|
59
|
+
<Fragment key={index}>
|
|
60
|
+
{item.separator && index > 0 ? <Divider/> : null}
|
|
61
|
+
<Button
|
|
62
|
+
label={item.label}
|
|
63
|
+
systemImage={item.icon ? iosSymbol(item.icon) : undefined}
|
|
64
|
+
role={item.role === 'destructive' ? 'destructive' : 'default'}
|
|
65
|
+
onPress={item.onPress}
|
|
66
|
+
modifiers={item.disabled ? [disabledMod(true)] : undefined}
|
|
67
|
+
/>
|
|
68
|
+
</Fragment>
|
|
69
|
+
))}
|
|
70
|
+
</>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import './menu.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {MenuProps} from './types';
|
|
4
|
+
import {useId, useRef} from 'react';
|
|
5
|
+
import {Button} from '../button';
|
|
6
|
+
import {MenuList, menuIdent} from './list';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* On web the trigger is the kit's `<button>` with a `popovertarget` pointing
|
|
10
|
+
* at the entries' native `popover`, so opening, closing, light dismiss and
|
|
11
|
+
* `aria-expanded` are all handled by the browser. The wrapper carries the
|
|
12
|
+
* `anchor-name` that CSS anchor positioning places the popup against.
|
|
13
|
+
*/
|
|
14
|
+
export function Menu({label, icon, items, testID, ...button}: MenuProps) {
|
|
15
|
+
const ident = menuIdent(useId());
|
|
16
|
+
const anchor = `--${ident}`;
|
|
17
|
+
const wrapper = useRef<HTMLSpanElement>(null);
|
|
18
|
+
return (
|
|
19
|
+
<span ref={wrapper} className="ui-menu" style={{anchorName: anchor} as CSSProperties}>
|
|
20
|
+
<Button
|
|
21
|
+
{...button}
|
|
22
|
+
label={label}
|
|
23
|
+
prefixIcon={icon}
|
|
24
|
+
popoverTarget={ident}
|
|
25
|
+
testID={testID}
|
|
26
|
+
/>
|
|
27
|
+
<MenuList id={ident} items={items} anchor={anchor} anchorRef={wrapper}/>
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type {CSSProperties, ToggleEvent} from 'react';
|
|
2
|
+
import type {MenuItem} from './types';
|
|
3
|
+
import {useRef} from 'react';
|
|
4
|
+
import {SymbolView} from 'expo-symbols';
|
|
5
|
+
|
|
6
|
+
const ICON_SIZE = 16;
|
|
7
|
+
const VIEWPORT_GAP = 8;
|
|
8
|
+
|
|
9
|
+
/** Whether the browser lays out `position-anchor` natively (Baseline 2026). */
|
|
10
|
+
const ANCHOR_SUPPORTED =
|
|
11
|
+
typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('position-anchor', '--ui-menu');
|
|
12
|
+
|
|
13
|
+
/** Turns a React `useId()` value into a valid CSS `<dashed-ident>` / HTML id. */
|
|
14
|
+
export function menuIdent(id: string): string {
|
|
15
|
+
return `ui-menu-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MenuListProps {
|
|
19
|
+
/** HTML id of the popover, referenced by the trigger's `popovertarget`. */
|
|
20
|
+
id: string;
|
|
21
|
+
items: MenuItem[];
|
|
22
|
+
/** `anchor-name` of the trigger; the popup is laid out relative to it. */
|
|
23
|
+
anchor?: string;
|
|
24
|
+
/** Element to measure when the browser lacks CSS anchor positioning. */
|
|
25
|
+
anchorRef?: React.RefObject<HTMLElement | null>;
|
|
26
|
+
/** Fixed viewport position (context menus) instead of an anchor. */
|
|
27
|
+
position?: {x: number; y: number} | null;
|
|
28
|
+
/** Exposes the popover element so callers can `showPopover()` programmatically. */
|
|
29
|
+
popoverRef?: React.RefObject<HTMLDivElement | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Web `role="menu"` popup shared by `Menu` and `ContextMenu`, rendered as a
|
|
34
|
+
* native `popover="auto"` element. The browser handles the top layer, light
|
|
35
|
+
* dismiss (outside click / Escape) and the trigger's `aria-expanded`; every
|
|
36
|
+
* item carries `popovertargetaction="hide"` so picking one closes the menu
|
|
37
|
+
* declaratively. Placement is CSS anchor positioning (see `menu.css`), with a
|
|
38
|
+
* measured fallback for engines without it.
|
|
39
|
+
*/
|
|
40
|
+
export function MenuList({id, items, anchor, anchorRef, position, popoverRef}: MenuListProps) {
|
|
41
|
+
const localRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const ref = popoverRef ?? localRef;
|
|
43
|
+
const anchored = !!anchor && !position;
|
|
44
|
+
|
|
45
|
+
const style: Record<string, string | number> = {};
|
|
46
|
+
if (anchored && ANCHOR_SUPPORTED) style.positionAnchor = anchor;
|
|
47
|
+
if (position) {
|
|
48
|
+
style.left = position.x;
|
|
49
|
+
style.top = position.y;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const onToggle = (event: ToggleEvent<HTMLDivElement>) => {
|
|
53
|
+
const popover = event.currentTarget;
|
|
54
|
+
if (event.newState !== 'open') return;
|
|
55
|
+
// Fallback placement: below the trigger, right-aligned, kept on screen.
|
|
56
|
+
if (anchored && !ANCHOR_SUPPORTED && anchorRef?.current) {
|
|
57
|
+
const rect = anchorRef.current.getBoundingClientRect();
|
|
58
|
+
popover.style.left = `${Math.max(VIEWPORT_GAP, rect.right - popover.offsetWidth)}px`;
|
|
59
|
+
popover.style.top = `${rect.bottom + 4}px`;
|
|
60
|
+
}
|
|
61
|
+
// Pointer placement: nudge back inside the viewport.
|
|
62
|
+
if (position) {
|
|
63
|
+
const maxX = window.innerWidth - popover.offsetWidth - VIEWPORT_GAP;
|
|
64
|
+
const maxY = window.innerHeight - popover.offsetHeight - VIEWPORT_GAP;
|
|
65
|
+
popover.style.left = `${Math.max(VIEWPORT_GAP, Math.min(position.x, maxX))}px`;
|
|
66
|
+
popover.style.top = `${Math.max(VIEWPORT_GAP, Math.min(position.y, maxY))}px`;
|
|
67
|
+
}
|
|
68
|
+
(popover.querySelector('button:not(:disabled)') as HTMLButtonElement | null)?.focus();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
ref={ref}
|
|
74
|
+
id={id}
|
|
75
|
+
role="menu"
|
|
76
|
+
popover="auto"
|
|
77
|
+
className={['ui-menu__list', anchored && ANCHOR_SUPPORTED && 'ui-menu__list--anchored'].filter(Boolean).join(' ')}
|
|
78
|
+
style={style as CSSProperties}
|
|
79
|
+
onToggle={onToggle}>
|
|
80
|
+
{items.map((item, index) => (
|
|
81
|
+
<div key={index}>
|
|
82
|
+
{item.separator && index > 0 ? <div className="ui-menu__separator" role="separator"/> : null}
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
role="menuitem"
|
|
86
|
+
className={['ui-menu__item', item.role === 'destructive' && 'ui-menu__item--destructive'].filter(Boolean).join(' ')}
|
|
87
|
+
disabled={item.disabled}
|
|
88
|
+
popoverTarget={id}
|
|
89
|
+
popoverTargetAction="hide"
|
|
90
|
+
onClick={item.onPress}>
|
|
91
|
+
{item.icon ? <SymbolView name={item.icon.symbol} size={ICON_SIZE} tintColor="currentColor"/> : null}
|
|
92
|
+
<span>{item.label}</span>
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|