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,432 @@
|
|
|
1
|
+
import type {GestureResponderEvent, LayoutChangeEvent} from 'react-native';
|
|
2
|
+
import type {RGBA} from './shared';
|
|
3
|
+
|
|
4
|
+
import {useCallback, useState} from 'react';
|
|
5
|
+
import {Pressable, StyleSheet, Text, TextInput, useColorScheme, View} from 'react-native';
|
|
6
|
+
import {Image} from 'expo-image';
|
|
7
|
+
import {fonts, fontWeights, useColor} from '../theme';
|
|
8
|
+
import {
|
|
9
|
+
checkerSvg,
|
|
10
|
+
grid,
|
|
11
|
+
gridColor,
|
|
12
|
+
parseColor,
|
|
13
|
+
spectrumColor,
|
|
14
|
+
spectrumPosition,
|
|
15
|
+
spectrumSvg,
|
|
16
|
+
svgDataUri,
|
|
17
|
+
toCss,
|
|
18
|
+
toHex,
|
|
19
|
+
useColorValue,
|
|
20
|
+
} from './shared';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The iOS system color picker (`UIColorPickerViewController`) redrawn with
|
|
24
|
+
* React Native views, shown in a bottom sheet on Android and web. A title
|
|
25
|
+
* row with a close button, a Grid / Spectrum / Sliders segmented control, an
|
|
26
|
+
* opacity slider and a footer with the preview swatch and saved colors.
|
|
27
|
+
* The spectrum and the checkerboard are static SVGs; the slider tracks are
|
|
28
|
+
* bands of solid segments, so dragging never decodes an image.
|
|
29
|
+
*/
|
|
30
|
+
export interface ColorPickerSheetProps {
|
|
31
|
+
/** Title of the picker, the row's label on iOS. */
|
|
32
|
+
title: string;
|
|
33
|
+
/** Selected color as `#RRGGBB` or `#RRGGBBAA`. */
|
|
34
|
+
value: string;
|
|
35
|
+
/** Shows the opacity slider. */
|
|
36
|
+
supportsOpacity: boolean;
|
|
37
|
+
/** Called with the new hex whenever the user picks a color. */
|
|
38
|
+
onValueChange: (hex: string) => void;
|
|
39
|
+
/** Called from the close button. */
|
|
40
|
+
onClose: () => void;
|
|
41
|
+
/** Fixed content width (Android sizes the hosted React Native tree from its content). */
|
|
42
|
+
width?: number;
|
|
43
|
+
/** Identifier used to locate the sheet in end-to-end tests. */
|
|
44
|
+
testID?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ColorPickerTab = 'grid' | 'spectrum' | 'sliders';
|
|
48
|
+
|
|
49
|
+
const TABS: {value: ColorPickerTab; label: string}[] = [
|
|
50
|
+
{value: 'grid', label: 'Grid'},
|
|
51
|
+
{value: 'spectrum', label: 'Spectrum'},
|
|
52
|
+
{value: 'sliders', label: 'Sliders'},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const SPECTRUM = svgDataUri(spectrumSvg());
|
|
56
|
+
const CHECKER = svgDataUri(checkerSvg());
|
|
57
|
+
const THUMB = 28;
|
|
58
|
+
const TRACK = 36;
|
|
59
|
+
const THUMB_INSET = (TRACK - THUMB) / 2;
|
|
60
|
+
const SEGMENTS = 32;
|
|
61
|
+
const HANDLE = 28;
|
|
62
|
+
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
|
63
|
+
|
|
64
|
+
/** Colors the user saved with the `+` button, shared by every picker for the session. */
|
|
65
|
+
let savedColors: RGBA[] = [];
|
|
66
|
+
|
|
67
|
+
const responder = (handler: (x: number, y: number) => void) => ({
|
|
68
|
+
onStartShouldSetResponder: () => true,
|
|
69
|
+
onMoveShouldSetResponder: () => true,
|
|
70
|
+
onResponderTerminationRequest: () => false,
|
|
71
|
+
onResponderGrant: (event: GestureResponderEvent) => handler(event.nativeEvent.locationX, event.nativeEvent.locationY),
|
|
72
|
+
onResponderMove: (event: GestureResponderEvent) => handler(event.nativeEvent.locationX, event.nativeEvent.locationY),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export function ColorPickerSheet({title, value, supportsOpacity, onValueChange, onClose, width, testID}: ColorPickerSheetProps) {
|
|
76
|
+
const [tab, setTab] = useState<ColorPickerTab>('grid');
|
|
77
|
+
const [color, setColor] = useColorValue(value, onValueChange, supportsOpacity);
|
|
78
|
+
const [saved, setSaved] = useState(savedColors);
|
|
79
|
+
const label = useColor('label');
|
|
80
|
+
const secondary = useColor('secondaryLabel');
|
|
81
|
+
const fill = useColor('pillBackground');
|
|
82
|
+
const separator = useColor('separator');
|
|
83
|
+
const css = toCss(color);
|
|
84
|
+
|
|
85
|
+
const save = () => {
|
|
86
|
+
savedColors = [...savedColors, color];
|
|
87
|
+
setSaved(savedColors);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<View style={[styles.sheet, width != null ? {width} : styles.fill]} testID={testID}>
|
|
92
|
+
<View style={styles.header}>
|
|
93
|
+
<Text style={[styles.title, {color: label}]} numberOfLines={1} role="heading" accessible>{title}</Text>
|
|
94
|
+
<Pressable
|
|
95
|
+
role="button"
|
|
96
|
+
aria-label="Close"
|
|
97
|
+
onPress={onClose}
|
|
98
|
+
style={[styles.close, {backgroundColor: fill}]}>
|
|
99
|
+
<Text style={[styles.closeGlyph, {color: secondary}]}>✕</Text>
|
|
100
|
+
</Pressable>
|
|
101
|
+
</View>
|
|
102
|
+
<Tabs value={tab} onChange={setTab}/>
|
|
103
|
+
<View style={styles.section}>
|
|
104
|
+
{tab === 'grid' ? <Grid color={color} onChange={setColor}/> : null}
|
|
105
|
+
{tab === 'spectrum' ? <Spectrum color={color} onChange={setColor}/> : null}
|
|
106
|
+
{tab === 'sliders' ? <Sliders color={color} onChange={setColor}/> : null}
|
|
107
|
+
</View>
|
|
108
|
+
{supportsOpacity ? (
|
|
109
|
+
<View style={styles.section}>
|
|
110
|
+
<Text style={[styles.caption, {color: secondary}]}>OPACITY</Text>
|
|
111
|
+
<View style={styles.sliderRow}>
|
|
112
|
+
<Slider
|
|
113
|
+
label="Opacity"
|
|
114
|
+
value={color.a}
|
|
115
|
+
checkered
|
|
116
|
+
colorAt={t => toCss({...color, a: t})}
|
|
117
|
+
onChange={a => setColor({...color, a})}
|
|
118
|
+
/>
|
|
119
|
+
<Field
|
|
120
|
+
label="Opacity percent"
|
|
121
|
+
value={`${Math.round(color.a * 100)}%`}
|
|
122
|
+
onCommit={text => setColor({...color, a: clamp(parseFloat(text) || 0, 0, 100) / 100})}
|
|
123
|
+
/>
|
|
124
|
+
</View>
|
|
125
|
+
</View>
|
|
126
|
+
) : null}
|
|
127
|
+
<View style={[styles.divider, {backgroundColor: separator}]}/>
|
|
128
|
+
<View style={styles.footer}>
|
|
129
|
+
<View style={styles.preview} aria-label={`Selected color ${toHex(color, supportsOpacity)}`}>
|
|
130
|
+
{color.a < 1 ? <Image source={{uri: CHECKER}} style={StyleSheet.absoluteFill} contentFit="cover"/> : null}
|
|
131
|
+
<View style={[StyleSheet.absoluteFill, {backgroundColor: css}]}/>
|
|
132
|
+
</View>
|
|
133
|
+
<View style={styles.saved}>
|
|
134
|
+
{saved.map((entry, index) => (
|
|
135
|
+
<Pressable
|
|
136
|
+
key={`${toHex(entry, true)}-${index}`}
|
|
137
|
+
role="button"
|
|
138
|
+
aria-label={`Saved color ${toHex(entry, true)}`}
|
|
139
|
+
onPress={() => setColor(entry)}
|
|
140
|
+
style={[styles.swatch, {backgroundColor: toCss(entry)}]}
|
|
141
|
+
/>
|
|
142
|
+
))}
|
|
143
|
+
<Pressable
|
|
144
|
+
role="button"
|
|
145
|
+
aria-label="Save color"
|
|
146
|
+
onPress={save}
|
|
147
|
+
style={[styles.swatch, {backgroundColor: fill}]}>
|
|
148
|
+
<Text style={[styles.plus, {color: secondary}]}>+</Text>
|
|
149
|
+
</Pressable>
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Tabs({value, onChange}: {value: ColorPickerTab; onChange: (tab: ColorPickerTab) => void}) {
|
|
157
|
+
const dark = useColorScheme() === 'dark';
|
|
158
|
+
const label = useColor('label');
|
|
159
|
+
const fill = useColor('pillBackground');
|
|
160
|
+
return (
|
|
161
|
+
<View style={[styles.tabs, {backgroundColor: fill}]} role="radiogroup" aria-label="Picker">
|
|
162
|
+
{TABS.map(tab => {
|
|
163
|
+
const selected = tab.value === value;
|
|
164
|
+
return (
|
|
165
|
+
<Pressable
|
|
166
|
+
key={tab.value}
|
|
167
|
+
role="radio"
|
|
168
|
+
aria-label={`${tab.label} tab`}
|
|
169
|
+
aria-checked={selected}
|
|
170
|
+
onPress={() => onChange(tab.value)}
|
|
171
|
+
style={[styles.tab, selected && [styles.tabSelected, {backgroundColor: dark ? '#636366' : '#ffffff'}]]}>
|
|
172
|
+
<Text style={[styles.tabLabel, {color: label}, selected && styles.tabLabelSelected]}>{tab.label}</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
);
|
|
175
|
+
})}
|
|
176
|
+
</View>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function Grid({color, onChange}: {color: RGBA; onChange: (next: RGBA) => void}) {
|
|
181
|
+
const rows = Array.from({length: grid.rows}, (_, row) =>
|
|
182
|
+
Array.from({length: grid.columns}, (_, column) => gridColor(row, column)),
|
|
183
|
+
);
|
|
184
|
+
return (
|
|
185
|
+
<View style={styles.grid}>
|
|
186
|
+
{rows.map((cells, row) => (
|
|
187
|
+
<View key={row} style={styles.gridRow}>
|
|
188
|
+
{cells.map((cell, column) => {
|
|
189
|
+
const selected = cell.r === color.r && cell.g === color.g && cell.b === color.b;
|
|
190
|
+
return (
|
|
191
|
+
<Pressable
|
|
192
|
+
key={column}
|
|
193
|
+
role="button"
|
|
194
|
+
aria-label={`Color ${toHex({...cell, a: 1}, false)}`}
|
|
195
|
+
aria-selected={selected}
|
|
196
|
+
onPress={() => onChange({...cell, a: color.a})}
|
|
197
|
+
style={[styles.gridCell, {backgroundColor: toCss({...cell, a: 1})}]}>
|
|
198
|
+
{selected ? <View style={styles.gridSelected}/> : null}
|
|
199
|
+
</Pressable>
|
|
200
|
+
);
|
|
201
|
+
})}
|
|
202
|
+
</View>
|
|
203
|
+
))}
|
|
204
|
+
</View>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function Spectrum({color, onChange}: {color: RGBA; onChange: (next: RGBA) => void}) {
|
|
209
|
+
const [size, setSize] = useState({width: 0, height: 0});
|
|
210
|
+
const position = spectrumPosition(color);
|
|
211
|
+
const pick = (x: number, y: number) => {
|
|
212
|
+
if (!size.width || !size.height) return;
|
|
213
|
+
onChange({...spectrumColor(x / size.width, y / size.height), a: color.a});
|
|
214
|
+
};
|
|
215
|
+
return (
|
|
216
|
+
<View
|
|
217
|
+
{...responder(pick)}
|
|
218
|
+
role="slider"
|
|
219
|
+
aria-label="Spectrum"
|
|
220
|
+
aria-valuetext={toHex(color, false)}
|
|
221
|
+
onLayout={(event: LayoutChangeEvent) => setSize(event.nativeEvent.layout)}
|
|
222
|
+
style={styles.spectrum}>
|
|
223
|
+
<Image source={{uri: SPECTRUM}} style={StyleSheet.absoluteFill} contentFit="fill"/>
|
|
224
|
+
{size.width > 0 ? (
|
|
225
|
+
<View
|
|
226
|
+
pointerEvents="none"
|
|
227
|
+
style={[
|
|
228
|
+
styles.handle,
|
|
229
|
+
{
|
|
230
|
+
left: clamp(position.x * size.width, HANDLE / 2, size.width - HANDLE / 2) - HANDLE / 2,
|
|
231
|
+
top: clamp(position.y * size.height, HANDLE / 2, size.height - HANDLE / 2) - HANDLE / 2,
|
|
232
|
+
backgroundColor: toCss({...color, a: 1}),
|
|
233
|
+
},
|
|
234
|
+
]}
|
|
235
|
+
/>
|
|
236
|
+
) : null}
|
|
237
|
+
</View>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const CHANNELS = ['r', 'g', 'b'] as const;
|
|
242
|
+
const CHANNEL_NAMES = {r: 'Red', g: 'Green', b: 'Blue'} as const;
|
|
243
|
+
|
|
244
|
+
function Sliders({color, onChange}: {color: RGBA; onChange: (next: RGBA) => void}) {
|
|
245
|
+
const secondary = useColor('secondaryLabel');
|
|
246
|
+
return (
|
|
247
|
+
<View style={styles.sliders}>
|
|
248
|
+
{CHANNELS.map(channel => (
|
|
249
|
+
<View key={channel}>
|
|
250
|
+
<Text style={[styles.caption, {color: secondary}]}>{CHANNEL_NAMES[channel].toUpperCase()}</Text>
|
|
251
|
+
<View style={styles.sliderRow}>
|
|
252
|
+
<Slider
|
|
253
|
+
label={CHANNEL_NAMES[channel]}
|
|
254
|
+
value={color[channel] / 255}
|
|
255
|
+
colorAt={t => toCss({...color, a: 1, [channel]: t * 255})}
|
|
256
|
+
onChange={t => onChange({...color, [channel]: Math.round(t * 255)})}
|
|
257
|
+
/>
|
|
258
|
+
<Field
|
|
259
|
+
label={`${CHANNEL_NAMES[channel]} value`}
|
|
260
|
+
value={String(color[channel])}
|
|
261
|
+
onCommit={text => onChange({...color, [channel]: clamp(Math.round(Number(text)) || 0, 0, 255)})}
|
|
262
|
+
/>
|
|
263
|
+
</View>
|
|
264
|
+
</View>
|
|
265
|
+
))}
|
|
266
|
+
<View style={styles.hexRow}>
|
|
267
|
+
<Text style={[styles.caption, styles.hexCaption, {color: secondary}]}>Display P3 Hex Color #</Text>
|
|
268
|
+
<Field
|
|
269
|
+
label="Hex color"
|
|
270
|
+
value={toHex(color, false).slice(1)}
|
|
271
|
+
wide
|
|
272
|
+
onCommit={text => {
|
|
273
|
+
if (!/^[0-9a-f]{3}$|^[0-9a-f]{6}$/i.test(text)) return;
|
|
274
|
+
onChange({...parseColor(text), a: color.a});
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
</View>
|
|
278
|
+
</View>
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
interface SliderProps {
|
|
283
|
+
label: string;
|
|
284
|
+
/** Position `0…1`. */
|
|
285
|
+
value: number;
|
|
286
|
+
/** Color of the track at a position `0…1`. */
|
|
287
|
+
colorAt: (t: number) => string;
|
|
288
|
+
/** Draws a checkerboard under the track (for translucent colors). */
|
|
289
|
+
checkered?: boolean;
|
|
290
|
+
onChange: (value: number) => void;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A pill track banded with `SEGMENTS` solid colors and a ringed thumb, like the iOS color sliders. */
|
|
294
|
+
function Slider({label, value, colorAt, checkered, onChange}: SliderProps) {
|
|
295
|
+
const [width, setWidth] = useState(0);
|
|
296
|
+
// The thumb travels inside the pill, inset by the ring around it.
|
|
297
|
+
const travel = Math.max(0, width - THUMB - 2 * THUMB_INSET);
|
|
298
|
+
const pick = (x: number) => {
|
|
299
|
+
if (!travel) return;
|
|
300
|
+
onChange(clamp((x - THUMB_INSET - THUMB / 2) / travel, 0, 1));
|
|
301
|
+
};
|
|
302
|
+
return (
|
|
303
|
+
<View
|
|
304
|
+
{...responder(pick)}
|
|
305
|
+
role="slider"
|
|
306
|
+
aria-label={label}
|
|
307
|
+
aria-valuemin={0}
|
|
308
|
+
aria-valuemax={100}
|
|
309
|
+
aria-valuenow={Math.round(value * 100)}
|
|
310
|
+
onLayout={(event: LayoutChangeEvent) => setWidth(event.nativeEvent.layout.width)}
|
|
311
|
+
style={styles.track}>
|
|
312
|
+
{checkered ? <Image source={{uri: CHECKER}} style={StyleSheet.absoluteFill} contentFit="cover"/> : null}
|
|
313
|
+
<View style={styles.band} pointerEvents="none">
|
|
314
|
+
{Array.from({length: SEGMENTS}, (_, i) => (
|
|
315
|
+
<View key={i} style={[styles.segment, {backgroundColor: colorAt((i + 0.5) / SEGMENTS)}]}/>
|
|
316
|
+
))}
|
|
317
|
+
</View>
|
|
318
|
+
{width > 0 ? (
|
|
319
|
+
<View
|
|
320
|
+
pointerEvents="none"
|
|
321
|
+
style={[styles.thumb, {left: THUMB_INSET + value * travel, backgroundColor: colorAt(value)}]}
|
|
322
|
+
/>
|
|
323
|
+
) : null}
|
|
324
|
+
</View>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface FieldProps {
|
|
329
|
+
label: string;
|
|
330
|
+
value: string;
|
|
331
|
+
wide?: boolean;
|
|
332
|
+
/** Called with the typed text when editing ends or the return key is pressed. */
|
|
333
|
+
onCommit: (text: string) => void;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Rounded value box that commits when editing ends, like the iOS picker's fields. */
|
|
337
|
+
function Field({label, value, wide, onCommit}: FieldProps) {
|
|
338
|
+
const [text, setText] = useState(value);
|
|
339
|
+
// Follow the picked color while the field is not being edited.
|
|
340
|
+
const [seen, setSeen] = useState(value);
|
|
341
|
+
if (seen !== value) {
|
|
342
|
+
setSeen(value);
|
|
343
|
+
setText(value);
|
|
344
|
+
}
|
|
345
|
+
const fill = useColor('pillBackground');
|
|
346
|
+
const color = useColor('label');
|
|
347
|
+
const commit = useCallback(() => onCommit(text), [onCommit, text]);
|
|
348
|
+
return (
|
|
349
|
+
<TextInput
|
|
350
|
+
aria-label={label}
|
|
351
|
+
value={text}
|
|
352
|
+
onChangeText={setText}
|
|
353
|
+
onSubmitEditing={commit}
|
|
354
|
+
onBlur={commit}
|
|
355
|
+
selectTextOnFocus
|
|
356
|
+
style={[styles.field, wide && styles.fieldWide, {backgroundColor: fill, color}]}
|
|
357
|
+
/>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const styles = StyleSheet.create({
|
|
362
|
+
sheet: {gap: 16},
|
|
363
|
+
fill: {alignSelf: 'stretch'},
|
|
364
|
+
header: {height: 44, alignItems: 'center', justifyContent: 'center'},
|
|
365
|
+
title: {
|
|
366
|
+
fontFamily: fonts?.sans,
|
|
367
|
+
fontSize: 17,
|
|
368
|
+
lineHeight: 22,
|
|
369
|
+
fontWeight: fontWeights.semibold,
|
|
370
|
+
marginHorizontal: 40,
|
|
371
|
+
textAlign: 'center',
|
|
372
|
+
},
|
|
373
|
+
close: {position: 'absolute', right: 0, width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center'},
|
|
374
|
+
closeGlyph: {fontFamily: fonts?.sans, fontSize: 15, lineHeight: 18, fontWeight: fontWeights.bold},
|
|
375
|
+
tabs: {flexDirection: 'row', padding: 2, borderRadius: 9},
|
|
376
|
+
tab: {flex: 1, height: 28, borderRadius: 7, alignItems: 'center', justifyContent: 'center'},
|
|
377
|
+
tabSelected: {
|
|
378
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.04)',
|
|
379
|
+
},
|
|
380
|
+
tabLabel: {fontFamily: fonts?.sans, fontSize: 13, lineHeight: 18, fontWeight: fontWeights.medium},
|
|
381
|
+
tabLabelSelected: {fontWeight: fontWeights.semibold},
|
|
382
|
+
section: {gap: 8},
|
|
383
|
+
caption: {fontFamily: fonts?.sans, fontSize: 13, lineHeight: 18, fontWeight: fontWeights.normal, letterSpacing: 0.3},
|
|
384
|
+
grid: {aspectRatio: grid.columns / grid.rows, borderRadius: 10, overflow: 'hidden'},
|
|
385
|
+
gridRow: {flex: 1, flexDirection: 'row'},
|
|
386
|
+
gridCell: {flex: 1},
|
|
387
|
+
gridSelected: {flex: 1, borderWidth: 3, borderColor: '#ffffff', outlineWidth: 1, outlineColor: 'rgba(0, 0, 0, 0.35)', outlineOffset: -1},
|
|
388
|
+
spectrum: {aspectRatio: 361 / 337, borderRadius: 10, overflow: 'hidden'},
|
|
389
|
+
handle: {
|
|
390
|
+
position: 'absolute',
|
|
391
|
+
width: HANDLE,
|
|
392
|
+
height: HANDLE,
|
|
393
|
+
borderRadius: HANDLE / 2,
|
|
394
|
+
borderWidth: 3,
|
|
395
|
+
borderColor: '#ffffff',
|
|
396
|
+
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.25)',
|
|
397
|
+
},
|
|
398
|
+
sliders: {gap: 12},
|
|
399
|
+
sliderRow: {flexDirection: 'row', alignItems: 'center', gap: 12},
|
|
400
|
+
track: {flex: 1, height: TRACK, borderRadius: TRACK / 2, overflow: 'hidden'},
|
|
401
|
+
band: {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, flexDirection: 'row'},
|
|
402
|
+
segment: {flex: 1},
|
|
403
|
+
thumb: {
|
|
404
|
+
position: 'absolute',
|
|
405
|
+
top: (TRACK - THUMB) / 2,
|
|
406
|
+
width: THUMB,
|
|
407
|
+
height: THUMB,
|
|
408
|
+
borderRadius: THUMB / 2,
|
|
409
|
+
borderWidth: 3,
|
|
410
|
+
borderColor: '#ffffff',
|
|
411
|
+
boxShadow: '0 1px 4px rgba(0, 0, 0, 0.25)',
|
|
412
|
+
},
|
|
413
|
+
field: {
|
|
414
|
+
width: 72,
|
|
415
|
+
height: TRACK,
|
|
416
|
+
borderRadius: 8,
|
|
417
|
+
paddingHorizontal: 8,
|
|
418
|
+
paddingVertical: 0,
|
|
419
|
+
fontFamily: fonts?.sans,
|
|
420
|
+
fontSize: 17,
|
|
421
|
+
textAlign: 'center',
|
|
422
|
+
},
|
|
423
|
+
fieldWide: {width: 112},
|
|
424
|
+
hexRow: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12},
|
|
425
|
+
hexCaption: {flexShrink: 1},
|
|
426
|
+
divider: {height: StyleSheet.hairlineWidth},
|
|
427
|
+
footer: {flexDirection: 'row', gap: 16},
|
|
428
|
+
preview: {width: 72, height: 72, borderRadius: 12, overflow: 'hidden'},
|
|
429
|
+
saved: {flex: 1, flexDirection: 'row', flexWrap: 'wrap', gap: 12, alignContent: 'flex-start'},
|
|
430
|
+
swatch: {width: 30, height: 30, borderRadius: 15, alignItems: 'center', justifyContent: 'center'},
|
|
431
|
+
plus: {fontFamily: fonts?.sans, fontSize: 20, lineHeight: 24, fontWeight: fontWeights.medium},
|
|
432
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {StyleProp, ViewStyle} from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cross-platform color picker: a row with a label and a color well that
|
|
5
|
+
* opens the system color picker.
|
|
6
|
+
*
|
|
7
|
+
* Bridges the SwiftUI `ColorPicker` on iOS. Android (Jetpack Compose) and
|
|
8
|
+
* web (DOM) redraw the iOS row — the label and the rainbow-ringed well — and
|
|
9
|
+
* open a sheet that reproduces the iOS picker: Grid, Spectrum and Sliders
|
|
10
|
+
* tabs, the opacity slider and the preview swatch with saved colors.
|
|
11
|
+
* A controlled control: pair `value` with `onValueChange`.
|
|
12
|
+
*/
|
|
13
|
+
export interface ColorPickerProps {
|
|
14
|
+
/** Label rendered at the leading edge of the row, and the title of the picker. */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Selected color as `#RRGGBB` or `#RRGGBBAA`. */
|
|
17
|
+
value: string;
|
|
18
|
+
/**
|
|
19
|
+
* Called with the new color whenever the user picks one, as `#RRGGBBAA`
|
|
20
|
+
* when `supportsOpacity` is on and `#RRGGBB` otherwise.
|
|
21
|
+
*/
|
|
22
|
+
onValueChange: (value: string) => void;
|
|
23
|
+
/**
|
|
24
|
+
* Shows the opacity slider and reports the alpha channel.
|
|
25
|
+
* @default true
|
|
26
|
+
*/
|
|
27
|
+
supportsOpacity?: boolean;
|
|
28
|
+
/** Disables interaction. */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Identifier used to locate the component in end-to-end tests. */
|
|
31
|
+
testID?: string;
|
|
32
|
+
/** Style applied to the row container (web only). */
|
|
33
|
+
style?: StyleProp<ViewStyle>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {ContextMenuProps} from '../menu/types';
|
|
2
|
+
|
|
3
|
+
import {useState} from 'react';
|
|
4
|
+
import {Box, DropdownMenu} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {combinedClickable, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {MenuItems} from '../menu/index.android';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Android wraps `children` in a `Box` with `combinedClickable`, so a
|
|
10
|
+
* long-press expands a Material 3 `DropdownMenu` anchored to it while a tap
|
|
11
|
+
* goes to `onPress`. `children` must be Compose content.
|
|
12
|
+
*/
|
|
13
|
+
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
14
|
+
const [expanded, setExpanded] = useState(false);
|
|
15
|
+
const modifiers = [
|
|
16
|
+
...(disabled ? [] : [combinedClickable({onClick: onPress, onLongClick: () => setExpanded(true)})]),
|
|
17
|
+
...(testID ? [testIDModifier(testID)] : []),
|
|
18
|
+
];
|
|
19
|
+
return (
|
|
20
|
+
<DropdownMenu expanded={expanded} onDismissRequest={() => setExpanded(false)}>
|
|
21
|
+
<DropdownMenu.Trigger>
|
|
22
|
+
<Box modifiers={modifiers}>{children}</Box>
|
|
23
|
+
</DropdownMenu.Trigger>
|
|
24
|
+
<MenuItems items={items} onClose={() => setExpanded(false)}/>
|
|
25
|
+
</DropdownMenu>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type {ContextMenuProps} from '../menu/types';
|
|
2
|
+
|
|
3
|
+
import {ContextMenu as SwiftUIContextMenu} from '@expo/ui/swift-ui';
|
|
4
|
+
import {onTapGesture} from '@expo/ui/swift-ui/modifiers';
|
|
5
|
+
import {MenuItems} from '../menu/index.ios';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS renders SwiftUI's `contextMenu`: a long-press on `children` lifts it
|
|
9
|
+
* into a preview with the entries beneath. `children` must be SwiftUI
|
|
10
|
+
* content. A plain tap is passed to `onPress` through `onTapGesture`.
|
|
11
|
+
*/
|
|
12
|
+
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
13
|
+
if (disabled) return <>{children}</>;
|
|
14
|
+
return (
|
|
15
|
+
<SwiftUIContextMenu modifiers={onPress ? [onTapGesture(onPress)] : undefined} testID={testID}>
|
|
16
|
+
<SwiftUIContextMenu.Trigger>{children}</SwiftUIContextMenu.Trigger>
|
|
17
|
+
<SwiftUIContextMenu.Items>
|
|
18
|
+
<MenuItems items={items}/>
|
|
19
|
+
</SwiftUIContextMenu.Items>
|
|
20
|
+
</SwiftUIContextMenu>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import '../menu/menu.css';
|
|
2
|
+
import type {MouseEvent, PointerEvent} from 'react';
|
|
3
|
+
import type {ContextMenuProps} from '../menu/types';
|
|
4
|
+
import {useId, useRef, useState} from 'react';
|
|
5
|
+
import {MenuList, menuIdent} from '../menu/list';
|
|
6
|
+
|
|
7
|
+
const LONG_PRESS_MS = 500;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* On web the entries live in the same native `popover="auto"` element as
|
|
11
|
+
* `Menu`, opened with `showPopover()` on right-click (`contextmenu`) or a
|
|
12
|
+
* touch long-press and placed at the pointer. The browser still owns the top
|
|
13
|
+
* layer and light dismiss. The wrapper is `display: contents`, so it doesn't
|
|
14
|
+
* affect the layout of `children`.
|
|
15
|
+
*/
|
|
16
|
+
export function ContextMenu({items, children, onPress, disabled, testID}: ContextMenuProps) {
|
|
17
|
+
const ident = menuIdent(useId());
|
|
18
|
+
const popover = useRef<HTMLDivElement>(null);
|
|
19
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
20
|
+
const [position, setPosition] = useState<{x: number; y: number} | null>(null);
|
|
21
|
+
|
|
22
|
+
const open = (x: number, y: number) => {
|
|
23
|
+
setPosition({x, y});
|
|
24
|
+
const element = popover.current;
|
|
25
|
+
if (element && !element.matches(':popover-open')) element.showPopover();
|
|
26
|
+
};
|
|
27
|
+
const onContextMenu = (event: MouseEvent) => {
|
|
28
|
+
if (disabled) return;
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
open(event.clientX, event.clientY);
|
|
31
|
+
};
|
|
32
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
33
|
+
if (disabled || event.pointerType !== 'touch') return;
|
|
34
|
+
const {clientX: x, clientY: y} = event;
|
|
35
|
+
timer.current = setTimeout(() => open(x, y), LONG_PRESS_MS);
|
|
36
|
+
};
|
|
37
|
+
const cancelPress = () => {
|
|
38
|
+
if (timer.current) clearTimeout(timer.current);
|
|
39
|
+
timer.current = null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
className="ui-context-menu"
|
|
45
|
+
onContextMenu={onContextMenu}
|
|
46
|
+
onPointerDown={onPointerDown}
|
|
47
|
+
onPointerUp={cancelPress}
|
|
48
|
+
onPointerCancel={cancelPress}
|
|
49
|
+
onPointerMove={cancelPress}
|
|
50
|
+
onClick={disabled ? undefined : onPress}
|
|
51
|
+
data-testid={testID}>
|
|
52
|
+
{children}
|
|
53
|
+
<MenuList id={ident} items={items} position={position} popoverRef={popover}/>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {DateTimePickerProps} from './types';
|
|
|
2
2
|
|
|
3
3
|
import {useState} from 'react';
|
|
4
4
|
import {useMaterialColors, Row, Text, Column, DatePickerDialog, TimePickerDialog} from '@expo/ui/jetpack-compose';
|
|
5
|
-
import {clip, Shapes, padding, clickable, background, fillMaxWidth} from '@expo/ui/jetpack-compose/modifiers';
|
|
5
|
+
import {clip, Shapes, padding, clickable, background, fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
6
|
import {useColor} from '../theme';
|
|
7
7
|
import {formatValue, useDateValue, withDatePart, withTimePart} from './shared';
|
|
8
8
|
|
|
@@ -22,6 +22,7 @@ export function DateTimePicker({
|
|
|
22
22
|
maximumDate,
|
|
23
23
|
disabled,
|
|
24
24
|
accentColor,
|
|
25
|
+
testID,
|
|
25
26
|
}: DateTimePickerProps) {
|
|
26
27
|
const [current, setValue] = useDateValue(value, onChange);
|
|
27
28
|
const [stage, setStage] = useState<'idle' | 'date' | 'time'>('idle');
|
|
@@ -69,7 +70,7 @@ export function DateTimePicker({
|
|
|
69
70
|
};
|
|
70
71
|
|
|
71
72
|
return (
|
|
72
|
-
<Column modifiers={[fillMaxWidth()]}>
|
|
73
|
+
<Column modifiers={[fillMaxWidth(), ...(testID ? [testIDModifier(testID)] : [])]}>
|
|
73
74
|
<Row
|
|
74
75
|
verticalAlignment="center"
|
|
75
76
|
horizontalArrangement="spaceBetween"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.ui-divider {
|
|
2
|
+
--ui-divider-color: var(--color-separator);
|
|
3
|
+
flex-shrink: 0;
|
|
4
|
+
align-self: stretch;
|
|
5
|
+
width: 100%;
|
|
6
|
+
height: 1px;
|
|
7
|
+
margin: 0;
|
|
8
|
+
border: none;
|
|
9
|
+
background: var(--ui-divider-color);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ui-divider--vertical {
|
|
13
|
+
width: 1px;
|
|
14
|
+
height: auto;
|
|
15
|
+
min-height: 1em;
|
|
16
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type {DividerProps} from './types';
|
|
2
|
+
|
|
3
|
+
import {StyleSheet} from 'react-native';
|
|
4
|
+
import {HorizontalDivider, VerticalDivider} from '@expo/ui/jetpack-compose';
|
|
5
|
+
import {padding, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
|
|
6
|
+
import {useColor} from '../theme';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Android renders the Material 3 `HorizontalDivider` (or `VerticalDivider`)
|
|
10
|
+
* as a single-pixel line in the theme `separator` color, matching iOS/web.
|
|
11
|
+
*/
|
|
12
|
+
export function Divider({vertical, color, inset, testID}: DividerProps) {
|
|
13
|
+
const separator = useColor('separator');
|
|
14
|
+
const Component = vertical ? VerticalDivider : HorizontalDivider;
|
|
15
|
+
const modifiers = [
|
|
16
|
+
...(inset ? [vertical ? padding(0, inset, 0, 0) : padding(inset, 0, 0, 0)] : []),
|
|
17
|
+
...(testID ? [testIDModifier(testID)] : []),
|
|
18
|
+
];
|
|
19
|
+
return (
|
|
20
|
+
<Component
|
|
21
|
+
color={color ?? separator}
|
|
22
|
+
thickness={StyleSheet.hairlineWidth}
|
|
23
|
+
modifiers={modifiers}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type {DividerProps} from './types';
|
|
2
|
+
import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
|
|
3
|
+
|
|
4
|
+
import {Divider as SwiftUIDivider} from '@expo/ui/swift-ui';
|
|
5
|
+
import {background, padding} from '@expo/ui/swift-ui/modifiers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* iOS renders SwiftUI's `Divider`, which is horizontal inside a `VStack` /
|
|
9
|
+
* `Form` and vertical inside an `HStack` on its own — `vertical` only picks
|
|
10
|
+
* the inset axis here, the surrounding stack decides the orientation. A custom
|
|
11
|
+
* `color` is painted with a `background` modifier (dividers take no tint).
|
|
12
|
+
*/
|
|
13
|
+
export function Divider({vertical, color, inset, testID}: DividerProps) {
|
|
14
|
+
const modifiers: ViewModifier[] = [];
|
|
15
|
+
if (inset) modifiers.push(padding(vertical ? {top: inset} : {leading: inset}));
|
|
16
|
+
if (color) modifiers.push(background(color));
|
|
17
|
+
return <SwiftUIDivider modifiers={modifiers} testID={testID}/>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import './divider.css';
|
|
2
|
+
import type {CSSProperties} from 'react';
|
|
3
|
+
import type {DividerProps} from './types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* On web the rule is a real `<hr>` element colored through the
|
|
7
|
+
* `--ui-divider-color` custom property (the theme `separator` by default).
|
|
8
|
+
*/
|
|
9
|
+
export function Divider({vertical, color, inset, testID}: DividerProps) {
|
|
10
|
+
const style: Record<string, string | number> = {};
|
|
11
|
+
if (color) style['--ui-divider-color'] = color;
|
|
12
|
+
if (inset) style[vertical ? 'marginTop' : 'marginLeft'] = inset;
|
|
13
|
+
return (
|
|
14
|
+
<hr
|
|
15
|
+
className={['ui-divider', vertical && 'ui-divider--vertical'].filter(Boolean).join(' ')}
|
|
16
|
+
aria-orientation={vertical ? 'vertical' : 'horizontal'}
|
|
17
|
+
style={style as CSSProperties}
|
|
18
|
+
data-testid={testID}
|
|
19
|
+
/>
|
|
20
|
+
);
|
|
21
|
+
}
|