expo-interface 0.1.1 → 0.3.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.
Files changed (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. package/src/qr/index.tsx +0 -26
@@ -0,0 +1,136 @@
1
+ /*
2
+ * The iOS ColorPicker row: label on the leading edge, the 28pt color well on
3
+ * the trailing edge. The well is a 3pt rainbow ring (yellow up, red right,
4
+ * blue down, green left), a 2pt transparent gap and an 18pt swatch.
5
+ */
6
+ .ui-color-picker {
7
+ -webkit-appearance: none;
8
+ appearance: none;
9
+ display: flex;
10
+ flex-direction: row;
11
+ align-items: center;
12
+ justify-content: space-between;
13
+ gap: 12px;
14
+ width: 100%;
15
+ margin: 0;
16
+ padding: 0;
17
+ border: none;
18
+ background: transparent;
19
+ color: var(--color-label);
20
+ font: inherit;
21
+ text-align: start;
22
+ cursor: pointer;
23
+ }
24
+
25
+ .ui-color-picker:disabled {
26
+ cursor: default;
27
+ }
28
+
29
+ .ui-color-picker--disabled {
30
+ opacity: 0.4;
31
+ }
32
+
33
+ .ui-color-picker__well {
34
+ position: relative;
35
+ flex-shrink: 0;
36
+ display: block;
37
+ width: 28px;
38
+ height: 28px;
39
+ border-radius: 50%;
40
+ background: conic-gradient(
41
+ from 0deg,
42
+ hsl(60 100% 50%),
43
+ hsl(0 100% 50%) 60deg,
44
+ hsl(300 100% 50%) 120deg,
45
+ hsl(240 100% 50%) 180deg,
46
+ hsl(180 100% 50%) 240deg,
47
+ hsl(120 100% 50%) 300deg,
48
+ hsl(60 100% 50%)
49
+ );
50
+ -webkit-mask: radial-gradient(circle, transparent 10.5px, #000 11px);
51
+ mask: radial-gradient(circle, transparent 10.5px, #000 11px);
52
+ }
53
+
54
+ .ui-color-picker__swatch {
55
+ position: absolute;
56
+ inset: 5px;
57
+ display: block;
58
+ border-radius: 50%;
59
+ background: var(--ui-color-picker-value);
60
+ }
61
+
62
+ .ui-color-picker:focus-visible {
63
+ outline: 2px solid var(--color-tint);
64
+ outline-offset: 2px;
65
+ border-radius: 4px;
66
+ }
67
+
68
+ /*
69
+ * With presets the row is a `<div>`; the swatches and the well are buttons of
70
+ * their own. A preset is a 28px circle in its color; the selected one shrinks
71
+ * inside a 30px ring in the label color.
72
+ */
73
+ .ui-color-picker--presets {
74
+ cursor: default;
75
+ }
76
+
77
+ .ui-color-picker__presets {
78
+ display: flex;
79
+ flex-direction: row;
80
+ align-items: center;
81
+ flex-shrink: 0;
82
+ gap: 8px;
83
+ }
84
+
85
+ .ui-color-picker__preset {
86
+ -webkit-appearance: none;
87
+ appearance: none;
88
+ position: relative;
89
+ width: 30px;
90
+ height: 30px;
91
+ margin: 0;
92
+ padding: 0;
93
+ border: none;
94
+ border-radius: 50%;
95
+ background: transparent;
96
+ cursor: pointer;
97
+ }
98
+
99
+ .ui-color-picker__preset::after {
100
+ content: '';
101
+ position: absolute;
102
+ inset: 1px;
103
+ border-radius: 50%;
104
+ background: var(--ui-color-picker-preset);
105
+ }
106
+
107
+ .ui-color-picker__preset--selected {
108
+ background: var(--color-label);
109
+ }
110
+
111
+ .ui-color-picker__preset--selected::after {
112
+ inset: 4px;
113
+ }
114
+
115
+ .ui-color-picker__open {
116
+ -webkit-appearance: none;
117
+ appearance: none;
118
+ display: flex;
119
+ margin: 0;
120
+ padding: 0;
121
+ border: none;
122
+ background: transparent;
123
+ cursor: pointer;
124
+ }
125
+
126
+ .ui-color-picker__preset:disabled,
127
+ .ui-color-picker__open:disabled {
128
+ cursor: default;
129
+ }
130
+
131
+ .ui-color-picker__preset:focus-visible,
132
+ .ui-color-picker__open:focus-visible {
133
+ outline: 2px solid var(--color-tint);
134
+ outline-offset: 2px;
135
+ border-radius: 50%;
136
+ }
@@ -0,0 +1,145 @@
1
+ import type {ColorPickerProps} from './types';
2
+ import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
3
+
4
+ import {useEffect, useRef, useState} from 'react';
5
+ import {useWindowDimensions} from 'react-native';
6
+ import {
7
+ Box,
8
+ Column,
9
+ ModalBottomSheet,
10
+ RNHostView,
11
+ Row,
12
+ Spacer,
13
+ Text,
14
+ useMaterialColors,
15
+ type ModalBottomSheetRef,
16
+ } from '@expo/ui/jetpack-compose';
17
+ import {alpha, background, clickable, clip, fillMaxWidth, padding, Shapes, size, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
18
+ import {useColor} from '../theme';
19
+ import {ColorPickerSheet} from './sheet';
20
+ import {parseColor, toCss, toHex, useColorValue, well} from './shared';
21
+
22
+ /** Horizontal inset of the sheet content (the `@expo/ui` `BottomSheet` default). */
23
+ const SHEET_INSET = 16;
24
+ /** Diameter of a preset swatch, and of its ring when selected. */
25
+ const SWATCH = 30;
26
+ const SWATCH_INNER = 28;
27
+ const SWATCH_SELECTED = 22;
28
+ const NONE = '#00000000';
29
+
30
+ /**
31
+ * Android redraws the iOS row in Compose through and through: a `Row` with
32
+ * the label and, at the trailing edge, the preset swatches and the 28dp
33
+ * color well (a circle in the color, ringed in `separator`). Tapping the row
34
+ * opens the iOS picker redrawn in a Material `ModalBottomSheet`, fully
35
+ * expanded and with the sheet's own swipe gestures off so that dragging
36
+ * across the spectrum and sliders stays with the picker; the sheet lives in
37
+ * its own window, so the row is a plain Compose child of its host and hosts
38
+ * no React Native view of its own (which a recomposing list would re-add).
39
+ */
40
+ export function ColorPicker({
41
+ label,
42
+ value,
43
+ onValueChange,
44
+ supportsOpacity = true,
45
+ swatches,
46
+ disabled,
47
+ testID,
48
+ }: ColorPickerProps) {
49
+ const colors = useMaterialColors();
50
+ const {width} = useWindowDimensions();
51
+ const ring = useColor('separator');
52
+ const labelColor = useColor('label');
53
+ const [open, setOpen] = useState(false);
54
+ const [current, setCurrent] = useColorValue(value, onValueChange, supportsOpacity);
55
+ const modifiers: ModifierConfig[] = [fillMaxWidth()];
56
+ if (!disabled) modifiers.push(clickable(() => setOpen(true)));
57
+ if (testID) modifiers.push(testIDModifier(testID));
58
+ const currentHex = toHex({...current, a: 1}, false);
59
+
60
+ return (
61
+ <Row verticalAlignment="center" horizontalArrangement="spaceBetween" modifiers={modifiers}>
62
+ {label != null ? (
63
+ <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
64
+ ) : <Spacer/>}
65
+ <Row verticalAlignment="center" horizontalArrangement={{spacedBy: 8}} modifiers={disabled ? [alpha(0.4)] : []}>
66
+ {swatches?.map(seed => {
67
+ const selected = toHex(parseColor(seed), false) === currentHex;
68
+ const inner = selected ? SWATCH_SELECTED : SWATCH_INNER;
69
+ return (
70
+ // The ring is a circle behind a smaller circle: a border modifier would be square.
71
+ <Box
72
+ key={seed}
73
+ contentAlignment="center"
74
+ modifiers={[
75
+ size(SWATCH, SWATCH),
76
+ clip(Shapes.Circle),
77
+ background(selected ? labelColor : NONE),
78
+ ...(disabled ? [] : [clickable(() => setCurrent({...parseColor(seed), a: current.a}))]),
79
+ ...(testID ? [testIDModifier(`${testID}-swatch-${seed}`)] : []),
80
+ ]}>
81
+ <Box modifiers={[size(inner, inner), clip(Shapes.Circle), background(seed)]}/>
82
+ </Box>
83
+ );
84
+ })}
85
+ <Box
86
+ contentAlignment="center"
87
+ modifiers={[
88
+ size(well.size, well.size),
89
+ clip(Shapes.Circle),
90
+ background(ring),
91
+ ...(testID ? [testIDModifier(`${testID}-well`)] : []),
92
+ ]}>
93
+ <Box
94
+ modifiers={[
95
+ size(well.size - 2 * well.ring, well.size - 2 * well.ring),
96
+ clip(Shapes.Circle),
97
+ background(toCss(current)),
98
+ ]}
99
+ />
100
+ </Box>
101
+ <PickerSheet open={open} onClose={() => setOpen(false)}>
102
+ <ColorPickerSheet
103
+ title={label ?? 'Colors'}
104
+ value={toHex(current, true)}
105
+ supportsOpacity={supportsOpacity}
106
+ onValueChange={hex => setCurrent(parseColor(hex))}
107
+ onClose={() => setOpen(false)}
108
+ width={width - SHEET_INSET * 2}
109
+ testID={testID ? `${testID}-sheet` : undefined}
110
+ />
111
+ </PickerSheet>
112
+ </Row>
113
+ </Row>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The Material bottom sheet hosting the picker, mounted while `open` and
119
+ * unmounted after its hide animation (the `@expo/ui` `BottomSheet` pattern).
120
+ * A Compose child of the row: the sheet presents in its own window, so the
121
+ * React Native picker inside it is hosted there, outside the form.
122
+ */
123
+ function PickerSheet({open, onClose, children}: React.PropsWithChildren<{open: boolean; onClose: () => void}>) {
124
+ const ref = useRef<ModalBottomSheetRef>(null);
125
+ const [mounted, setMounted] = useState(open);
126
+ if (open && !mounted) setMounted(true);
127
+ useEffect(() => {
128
+ if (open) return;
129
+ let cancelled = false;
130
+ ref.current?.hide().then(() => {
131
+ if (!cancelled) setMounted(false);
132
+ });
133
+ return () => {
134
+ cancelled = true;
135
+ };
136
+ }, [open]);
137
+ if (!mounted) return null;
138
+ return (
139
+ <ModalBottomSheet ref={ref} onDismissRequest={onClose} skipPartiallyExpanded sheetGesturesEnabled={false}>
140
+ <Column modifiers={[padding(SHEET_INSET, 0, SHEET_INSET, 0)]}>
141
+ <RNHostView matchContents>{children as React.ReactElement}</RNHostView>
142
+ </Column>
143
+ </ModalBottomSheet>
144
+ );
145
+ }
@@ -0,0 +1,80 @@
1
+ import type {ColorPickerProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {Button, ColorPicker as SwiftUIColorPicker, HStack, Spacer, Text, ZStack} from '@expo/ui/swift-ui';
5
+ import {accessibilityLabel, background, buttonStyle, disabled as disabledMod, frame, labelsHidden, shapes} from '@expo/ui/swift-ui/modifiers';
6
+ import {useColor} from '../theme';
7
+ import {parseColor, toHex} from './shared';
8
+
9
+ /** Diameter of a preset swatch, and of its ring when selected. */
10
+ const SWATCH = 30;
11
+ const SWATCH_INNER = 28;
12
+ const SWATCH_SELECTED = 22;
13
+ const NONE = '#00000000';
14
+
15
+ /**
16
+ * iOS renders SwiftUI's `ColorPicker`: the label with the rainbow-ringed
17
+ * color well at the trailing edge, which opens the system color picker.
18
+ * SwiftUI reports the selection as `#RRGGBBAA` when opacity is supported and
19
+ * `#RRGGBB` otherwise; the other platforms mirror both formats. With
20
+ * `swatches` the row is composed by hand: the label, a spacer, the preset
21
+ * circles (the selected one ringed in the label color) and the picker's
22
+ * well with its own label hidden.
23
+ */
24
+ export function ColorPicker({
25
+ label,
26
+ value,
27
+ onValueChange,
28
+ supportsOpacity = true,
29
+ swatches,
30
+ disabled,
31
+ testID,
32
+ }: ColorPickerProps) {
33
+ const labelColor = useColor('label');
34
+ const modifiers: ViewModifier[] = [];
35
+ if (disabled) modifiers.push(disabledMod(true));
36
+ if (!swatches?.length) {
37
+ return (
38
+ <SwiftUIColorPicker
39
+ label={label}
40
+ selection={value}
41
+ supportsOpacity={supportsOpacity}
42
+ onSelectionChange={onValueChange}
43
+ modifiers={modifiers}
44
+ testID={testID}
45
+ />
46
+ );
47
+ }
48
+ const current = parseColor(value);
49
+ const currentHex = toHex({...current, a: 1}, false);
50
+ return (
51
+ <HStack spacing={8} modifiers={modifiers} testID={testID}>
52
+ {label != null ? <Text>{label}</Text> : null}
53
+ <Spacer/>
54
+ {swatches.map(seed => {
55
+ const selected = toHex(parseColor(seed), false) === currentHex;
56
+ const inner = selected ? SWATCH_SELECTED : SWATCH_INNER;
57
+ return (
58
+ <Button
59
+ key={seed}
60
+ onPress={() => onValueChange(toHex({...parseColor(seed), a: current.a}, supportsOpacity))}
61
+ modifiers={[buttonStyle('plain'), accessibilityLabel(`Color ${seed}`)]}>
62
+ <ZStack modifiers={[frame({width: SWATCH, height: SWATCH}), background(selected ? labelColor : NONE, shapes.circle())]}>
63
+ <ZStack modifiers={[frame({width: inner, height: inner}), background(seed, shapes.circle())]}>
64
+ <Spacer/>
65
+ </ZStack>
66
+ </ZStack>
67
+ </Button>
68
+ );
69
+ })}
70
+ <SwiftUIColorPicker
71
+ label={label}
72
+ selection={value}
73
+ supportsOpacity={supportsOpacity}
74
+ onSelectionChange={onValueChange}
75
+ modifiers={[labelsHidden()]}
76
+ testID={testID ? `${testID}-well` : undefined}
77
+ />
78
+ </HStack>
79
+ );
80
+ }
@@ -0,0 +1,112 @@
1
+ import './color-picker.css';
2
+ import type {CSSProperties} from 'react';
3
+ import type {ColorPickerProps} from './types';
4
+
5
+ import {useState} from 'react';
6
+ import {StyleSheet, type TextStyle} from 'react-native';
7
+ import {Label} from '../typography';
8
+ import {Sheet} from '../sheet';
9
+ import {flatten} from '../theme';
10
+ import {ColorPickerSheet} from './sheet';
11
+ import {parseColor, toCss, toHex, useColorValue} from './shared';
12
+
13
+ /**
14
+ * On web the row is a native `<button>` holding the label and the color
15
+ * well — a conic rainbow ring around the selected color, as on iOS — which
16
+ * opens the iOS picker redrawn in the kit's `Sheet`. With `swatches` the row
17
+ * is a `<div>` instead, so each preset and the well are buttons of their
18
+ * own (buttons cannot nest).
19
+ */
20
+ export function ColorPicker({
21
+ label,
22
+ value,
23
+ onValueChange,
24
+ supportsOpacity = true,
25
+ swatches,
26
+ disabled,
27
+ testID,
28
+ style,
29
+ }: ColorPickerProps) {
30
+ const [open, setOpen] = useState(false);
31
+ const [current, setCurrent] = useColorValue(value, onValueChange, supportsOpacity);
32
+ const vars = {
33
+ '--ui-color-picker-value': toCss(current),
34
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
35
+ } as CSSProperties;
36
+ const className = ['ui-color-picker', disabled && 'ui-color-picker--disabled'].filter(Boolean).join(' ');
37
+ const well = (
38
+ <span className="ui-color-picker__well" aria-hidden="true">
39
+ <span className="ui-color-picker__swatch"/>
40
+ </span>
41
+ );
42
+ const sheet = (
43
+ <Sheet isPresented={open} onDismiss={() => setOpen(false)}>
44
+ <ColorPickerSheet
45
+ title={label ?? 'Colors'}
46
+ value={toHex(current, true)}
47
+ supportsOpacity={supportsOpacity}
48
+ onValueChange={hex => setCurrent(parseColor(hex))}
49
+ onClose={() => setOpen(false)}
50
+ testID={testID ? `${testID}-sheet` : undefined}
51
+ />
52
+ </Sheet>
53
+ );
54
+
55
+ if (swatches?.length) {
56
+ const currentHex = toHex({...current, a: 1}, false);
57
+ return (
58
+ <>
59
+ <div className={`${className} ui-color-picker--presets`} style={vars} data-testid={testID}>
60
+ {label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
61
+ <span className="ui-color-picker__presets">
62
+ {swatches.map(seed => {
63
+ const selected = toHex(parseColor(seed), false) === currentHex;
64
+ return (
65
+ <button
66
+ key={seed}
67
+ type="button"
68
+ className={['ui-color-picker__preset', selected && 'ui-color-picker__preset--selected'].filter(Boolean).join(' ')}
69
+ style={{'--ui-color-picker-preset': seed} as CSSProperties}
70
+ aria-label={`Color ${seed}`}
71
+ aria-pressed={selected}
72
+ disabled={disabled}
73
+ onClick={() => setCurrent({...parseColor(seed), a: current.a})}
74
+ />
75
+ );
76
+ })}
77
+ <button
78
+ type="button"
79
+ className="ui-color-picker__open"
80
+ aria-label={label ?? 'Color'}
81
+ aria-haspopup="dialog"
82
+ aria-expanded={open}
83
+ disabled={disabled}
84
+ onClick={() => setOpen(true)}>
85
+ {well}
86
+ </button>
87
+ </span>
88
+ </div>
89
+ {sheet}
90
+ </>
91
+ );
92
+ }
93
+
94
+ return (
95
+ <>
96
+ <button
97
+ type="button"
98
+ className={className}
99
+ style={vars}
100
+ disabled={disabled}
101
+ aria-label={label == null ? 'Color' : undefined}
102
+ aria-haspopup="dialog"
103
+ aria-expanded={open}
104
+ onClick={() => setOpen(true)}
105
+ data-testid={testID}>
106
+ {label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
107
+ {well}
108
+ </button>
109
+ {sheet}
110
+ </>
111
+ );
112
+ }
@@ -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
+ }