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,204 @@
1
+ import {useCallback, useState} from 'react';
2
+
3
+ /** Color channels: `r`, `g`, `b` in `0…255`, `a` in `0…1`. */
4
+ export interface RGBA {
5
+ r: number;
6
+ g: number;
7
+ b: number;
8
+ a: number;
9
+ }
10
+
11
+ /** Geometry of the iOS color well, in points: a 28pt circle ringed by hues. */
12
+ export const well = {size: 28, ring: 3, gap: 2} as const;
13
+
14
+ /** iOS color grid: 12 hue columns, a gray row above 9 lightness rows. */
15
+ export const grid = {columns: 12, rows: 10} as const;
16
+
17
+ const HUES = [0, 30, 55, 80, 120, 160, 190, 210, 240, 270, 300, 330];
18
+
19
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
20
+ const byte = (n: number) => clamp(Math.round(n), 0, 255);
21
+ const hex2 = (n: number) => byte(n).toString(16).padStart(2, '0').toUpperCase();
22
+
23
+ /** Parses `#RGB`, `#RGBA`, `#RRGGBB` or `#RRGGBBAA`; anything else is opaque black. */
24
+ export function parseColor(input: string): RGBA {
25
+ const hex = /^#?([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(input.trim())?.[1];
26
+ if (!hex) return {r: 0, g: 0, b: 0, a: 1};
27
+ const long = hex.length <= 4 ? hex.split('').map(c => c + c).join('') : hex;
28
+ const channel = (i: number) => parseInt(long.slice(i, i + 2), 16);
29
+ return {
30
+ r: channel(0),
31
+ g: channel(2),
32
+ b: channel(4),
33
+ a: long.length === 8 ? channel(6) / 255 : 1,
34
+ };
35
+ }
36
+
37
+ /** Formats as `#RRGGBBAA` when `alpha` is set, `#RRGGBB` otherwise (the iOS formats). */
38
+ export function toHex({r, g, b, a}: RGBA, alpha: boolean): string {
39
+ const rgb = `#${hex2(r)}${hex2(g)}${hex2(b)}`;
40
+ return alpha ? `${rgb}${hex2(a * 255)}` : rgb;
41
+ }
42
+
43
+ /** CSS `rgba()` for the color, usable in DOM and React Native styles. */
44
+ export function toCss({r, g, b, a}: RGBA): string {
45
+ return `rgba(${byte(r)}, ${byte(g)}, ${byte(b)}, ${Math.round(a * 1000) / 1000})`;
46
+ }
47
+
48
+ /** Hue `0…360`, saturation and value `0…1`. */
49
+ export function rgbToHsv({r, g, b}: Pick<RGBA, 'r' | 'g' | 'b'>): {h: number; s: number; v: number} {
50
+ const max = Math.max(r, g, b) / 255;
51
+ const min = Math.min(r, g, b) / 255;
52
+ const delta = max - min;
53
+ let h = 0;
54
+ if (delta > 0) {
55
+ if (max === r / 255) h = ((g - b) / 255 / delta) % 6;
56
+ else if (max === g / 255) h = (b - r) / 255 / delta + 2;
57
+ else h = (r - g) / 255 / delta + 4;
58
+ h = (h * 60 + 360) % 360;
59
+ }
60
+ return {h, s: max === 0 ? 0 : delta / max, v: max};
61
+ }
62
+
63
+ export function hsvToRgb(h: number, s: number, v: number): Pick<RGBA, 'r' | 'g' | 'b'> {
64
+ const hue = ((h % 360) + 360) % 360;
65
+ const c = v * s;
66
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
67
+ const m = v - c;
68
+ const sector = Math.floor(hue / 60);
69
+ const [r, g, b] = [
70
+ [c, x, 0],
71
+ [x, c, 0],
72
+ [0, c, x],
73
+ [0, x, c],
74
+ [x, 0, c],
75
+ [c, 0, x],
76
+ ][sector];
77
+ return {r: byte((r + m) * 255), g: byte((g + m) * 255), b: byte((b + m) * 255)};
78
+ }
79
+
80
+ export function hslToRgb(h: number, s: number, l: number): Pick<RGBA, 'r' | 'g' | 'b'> {
81
+ const v = l + s * Math.min(l, 1 - l);
82
+ return hsvToRgb(h, v === 0 ? 0 : 2 * (1 - l / v), v);
83
+ }
84
+
85
+ /** Color of a cell of the iOS grid: grays on the first row, then hues from light to dark. */
86
+ export function gridColor(row: number, column: number): Pick<RGBA, 'r' | 'g' | 'b'> {
87
+ if (row === 0) {
88
+ const level = byte(255 * (1 - column / (grid.columns - 1)));
89
+ return {r: level, g: level, b: level};
90
+ }
91
+ const lightness = 0.9 - ((row - 1) / (grid.rows - 2)) * 0.75;
92
+ return hslToRgb(HUES[column], 1, lightness);
93
+ }
94
+
95
+ /**
96
+ * Color at a point of the iOS spectrum: hue runs down the vertical axis, and
97
+ * the horizontal axis goes from white through the pure hue to black.
98
+ */
99
+ export function spectrumColor(x: number, y: number): Pick<RGBA, 'r' | 'g' | 'b'> {
100
+ const fx = clamp(x, 0, 1);
101
+ const h = clamp(y, 0, 1) * 360;
102
+ return fx <= 0.5 ? hsvToRgb(h, fx * 2, 1) : hsvToRgb(h, 1, (1 - fx) * 2);
103
+ }
104
+
105
+ /** Closest spectrum point (`0…1` each) for a color. */
106
+ export function spectrumPosition(color: Pick<RGBA, 'r' | 'g' | 'b'>): {x: number; y: number} {
107
+ const {h, s, v} = rgbToHsv(color);
108
+ return {x: v < 1 ? 1 - v / 2 : s / 2, y: h / 360};
109
+ }
110
+
111
+ /** Base64 of an ASCII string (no `btoa` on every React Native runtime). */
112
+ function base64(ascii: string): string {
113
+ const table = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
114
+ let out = '';
115
+ for (let i = 0; i < ascii.length; i += 3) {
116
+ const a = ascii.charCodeAt(i);
117
+ const b = ascii.charCodeAt(i + 1);
118
+ const c = ascii.charCodeAt(i + 2);
119
+ const bits = (a << 16) | ((b || 0) << 8) | (c || 0);
120
+ out += table[bits >> 18] + table[(bits >> 12) & 63];
121
+ out += Number.isNaN(b) ? '=' : table[(bits >> 6) & 63];
122
+ out += Number.isNaN(c) ? '=' : table[bits & 63];
123
+ }
124
+ return out;
125
+ }
126
+
127
+ export function svgDataUri(svg: string): string {
128
+ return `data:image/svg+xml;base64,${base64(svg)}`;
129
+ }
130
+
131
+ /** Hue at a clockwise angle from the top of the well ring (yellow up, red right, blue down, green left). */
132
+ export const ringHue = (angle: number) => (60 - angle + 360) % 360;
133
+
134
+ /** The well's rainbow ring as an SVG: 60 arc segments around a transparent center. */
135
+ export function ringSvg(): string {
136
+ const r = 50 - (well.ring / well.size) * 50;
137
+ const stroke = (well.ring / well.size) * 100;
138
+ const step = 6;
139
+ const point = (deg: number) => {
140
+ const a = ((deg - 90) * Math.PI) / 180;
141
+ return `${(50 + r * Math.cos(a)).toFixed(2)} ${(50 + r * Math.sin(a)).toFixed(2)}`;
142
+ };
143
+ let paths = '';
144
+ for (let deg = 0; deg < 360; deg += step) {
145
+ const {r: cr, g: cg, b: cb} = hsvToRgb(ringHue(deg + step / 2), 1, 1);
146
+ paths += `<path d="M${point(deg)}A${r} ${r} 0 0 1 ${point(deg + step + 0.5)}" stroke="rgb(${cr},${cg},${cb})"/>`;
147
+ }
148
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none" stroke-width="${stroke}">${paths}</svg>`;
149
+ }
150
+
151
+ /** The spectrum as an SVG: a vertical hue gradient under a white→clear→black one. */
152
+ export function spectrumSvg(): string {
153
+ const hues = [0, 60, 120, 180, 240, 300, 360]
154
+ .map(h => {
155
+ const {r, g, b} = hsvToRgb(h, 1, 1);
156
+ return `<stop offset="${h / 3.6}%" stop-color="rgb(${r},${g},${b})"/>`;
157
+ })
158
+ .join('');
159
+ return (
160
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="none">' +
161
+ `<linearGradient id="h" x1="0" y1="0" x2="0" y2="1">${hues}</linearGradient>` +
162
+ '<linearGradient id="l" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff"/>' +
163
+ '<stop offset="0.5" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#000" stop-opacity="0"/>' +
164
+ '<stop offset="1" stop-color="#000"/></linearGradient>' +
165
+ '<rect width="100" height="100" fill="url(#h)"/><rect width="100" height="100" fill="url(#l)"/></svg>'
166
+ );
167
+ }
168
+
169
+ /** Checkerboard shown behind translucent colors. */
170
+ export function checkerSvg(): string {
171
+ return (
172
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">' +
173
+ '<rect width="16" height="16" fill="#fff"/><rect width="8" height="8" fill="#d9d9d9"/>' +
174
+ '<rect x="8" y="8" width="8" height="8" fill="#d9d9d9"/></svg>'
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Controlled color state for the pickers: the picked color is kept locally so
180
+ * the sheet follows the user's drag even before the parent re-renders, and
181
+ * every change is reported in the iOS hex format.
182
+ */
183
+ export function useColorValue(
184
+ value: string,
185
+ onValueChange: (hex: string) => void,
186
+ supportsOpacity: boolean,
187
+ ): [RGBA, (next: RGBA) => void] {
188
+ const [current, setCurrent] = useState(() => parseColor(value));
189
+ // Re-derive the local color when the controlling parent changes `value`.
190
+ const [seen, setSeen] = useState(value);
191
+ if (seen !== value) {
192
+ setSeen(value);
193
+ setCurrent(parseColor(value));
194
+ }
195
+ const update = useCallback(
196
+ (next: RGBA) => {
197
+ const color = supportsOpacity ? next : {...next, a: 1};
198
+ setCurrent(color);
199
+ onValueChange(toHex(color, supportsOpacity));
200
+ },
201
+ [onValueChange, supportsOpacity],
202
+ );
203
+ return [current, update];
204
+ }
@@ -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
+ }