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.
Files changed (88) hide show
  1. package/README.md +282 -180
  2. package/package.json +39 -13
  3. package/src/alert/alert.css +49 -0
  4. package/src/alert/index.android.tsx +68 -0
  5. package/src/alert/index.ios.tsx +47 -0
  6. package/src/alert/index.tsx +60 -0
  7. package/src/alert/shared.ts +10 -0
  8. package/src/alert/types.ts +58 -0
  9. package/src/button/button.css +6 -0
  10. package/src/button/index.android.tsx +9 -2
  11. package/src/button/index.ios.tsx +8 -3
  12. package/src/button/index.tsx +19 -1
  13. package/src/button/types.ts +7 -0
  14. package/src/checkbox/checkbox.css +29 -0
  15. package/src/checkbox/index.android.tsx +54 -0
  16. package/src/checkbox/index.ios.tsx +50 -0
  17. package/src/checkbox/index.tsx +50 -0
  18. package/src/checkbox/types.ts +27 -0
  19. package/src/collapsible/collapsible.css +32 -0
  20. package/src/collapsible/index.android.tsx +34 -0
  21. package/src/collapsible/index.ios.tsx +31 -0
  22. package/src/collapsible/index.tsx +41 -0
  23. package/src/collapsible/shared.ts +21 -0
  24. package/src/collapsible/types.ts +24 -0
  25. package/src/color-picker/color-picker.css +66 -0
  26. package/src/color-picker/index.android.tsx +117 -0
  27. package/src/color-picker/index.ios.tsx +33 -0
  28. package/src/color-picker/index.tsx +62 -0
  29. package/src/color-picker/shared.ts +204 -0
  30. package/src/color-picker/sheet.tsx +432 -0
  31. package/src/color-picker/types.ts +34 -0
  32. package/src/context-menu/index.android.tsx +27 -0
  33. package/src/context-menu/index.ios.tsx +22 -0
  34. package/src/context-menu/index.tsx +56 -0
  35. package/src/date-time/index.android.tsx +3 -2
  36. package/src/divider/divider.css +16 -0
  37. package/src/divider/index.android.tsx +26 -0
  38. package/src/divider/index.ios.tsx +18 -0
  39. package/src/divider/index.tsx +21 -0
  40. package/src/divider/types.ts +20 -0
  41. package/src/field-group/field-group.css +23 -7
  42. package/src/field-group/index.android.tsx +22 -22
  43. package/src/field-group/index.web.tsx +2 -2
  44. package/src/gauge/gauge.css +200 -0
  45. package/src/gauge/index.android.tsx +212 -0
  46. package/src/gauge/index.ios.tsx +40 -0
  47. package/src/gauge/index.tsx +165 -0
  48. package/src/gauge/shared.ts +79 -0
  49. package/src/gauge/types.ts +64 -0
  50. package/src/index.ts +24 -3
  51. package/src/menu/index.android.tsx +66 -0
  52. package/src/menu/index.ios.tsx +72 -0
  53. package/src/menu/index.tsx +30 -0
  54. package/src/menu/list.tsx +98 -0
  55. package/src/menu/menu.css +83 -0
  56. package/src/menu/types.ts +60 -0
  57. package/src/picker/index.android.tsx +3 -2
  58. package/src/progress/index.android.tsx +23 -9
  59. package/src/progress/index.ios.tsx +7 -6
  60. package/src/progress/index.tsx +43 -6
  61. package/src/progress/progress.css +33 -0
  62. package/src/progress/types.ts +23 -6
  63. package/src/screen/header.tsx +1 -0
  64. package/src/segmented/index.android.tsx +67 -0
  65. package/src/segmented/index.ios.tsx +47 -0
  66. package/src/segmented/index.tsx +58 -0
  67. package/src/segmented/segmented.css +55 -0
  68. package/src/segmented/types.ts +31 -0
  69. package/src/slider/index.android.tsx +87 -0
  70. package/src/slider/index.ios.tsx +64 -0
  71. package/src/slider/index.tsx +55 -0
  72. package/src/slider/slider.css +30 -0
  73. package/src/slider/types.ts +39 -0
  74. package/src/stepper/index.android.tsx +58 -0
  75. package/src/stepper/index.ios.tsx +55 -0
  76. package/src/stepper/index.tsx +47 -0
  77. package/src/stepper/shared.ts +17 -0
  78. package/src/stepper/stepper.css +61 -0
  79. package/src/stepper/types.ts +35 -0
  80. package/src/switch/index.tsx +2 -0
  81. package/src/text-field/shared.ts +2 -0
  82. package/src/tooltip/index.android.tsx +23 -0
  83. package/src/tooltip/index.ios.tsx +17 -0
  84. package/src/tooltip/index.tsx +41 -0
  85. package/src/tooltip/tooltip.css +40 -0
  86. package/src/tooltip/types.ts +25 -0
  87. package/src/link.ts +0 -36
  88. package/src/qr/index.tsx +0 -26
@@ -0,0 +1,34 @@
1
+ import type {CollapsibleProps} from './types';
2
+
3
+ import {Collapsible as UICollapsible} from '@expo/ui';
4
+ import {Column} from '@expo/ui/jetpack-compose';
5
+ import {fillMaxWidth, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
6
+ import {useColor} from '../theme';
7
+ import {useExpanded} from './shared';
8
+
9
+ /**
10
+ * Android renders `@expo/ui`'s universal `Collapsible`, which is already the
11
+ * Material 3 Expressive expandable list item (rounded card whose container
12
+ * tints from transparent to `surfaceContainer` while open, animated chevron).
13
+ * It reads the seeded Host palette, so it matches the rest of the screen.
14
+ */
15
+ export function Collapsible({
16
+ label,
17
+ expanded,
18
+ defaultExpanded = false,
19
+ onExpandedChange,
20
+ children,
21
+ testID,
22
+ }: CollapsibleProps) {
23
+ const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
24
+ const labelColor = useColor('label');
25
+ const collapsible = (
26
+ <UICollapsible isOpen={open} onOpenChange={setOpen} label={label} labelStyle={{color: labelColor}}>
27
+ {children}
28
+ </UICollapsible>
29
+ );
30
+ // `@expo/ui`'s universal Collapsible takes no testID, so a full-width
31
+ // Compose wrapper carries it.
32
+ if (testID == null) return collapsible;
33
+ return <Column modifiers={[fillMaxWidth(), testIDModifier(testID)]}>{collapsible}</Column>;
34
+ }
@@ -0,0 +1,31 @@
1
+ import type {CollapsibleProps} from './types';
2
+
3
+ import {DisclosureGroup, Text} from '@expo/ui/swift-ui';
4
+ import {foregroundStyle} from '@expo/ui/swift-ui/modifiers';
5
+ import {useColor} from '../theme';
6
+ import {useExpanded} from './shared';
7
+
8
+ /**
9
+ * iOS renders SwiftUI's `DisclosureGroup`: a row with the label and a
10
+ * trailing chevron that rotates open, with the content revealed below. Drop
11
+ * it straight into a `FieldGroup.Section`.
12
+ */
13
+ export function Collapsible({
14
+ label,
15
+ expanded,
16
+ defaultExpanded = false,
17
+ onExpandedChange,
18
+ children,
19
+ testID,
20
+ }: CollapsibleProps) {
21
+ const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
22
+ const labelColor = useColor('label');
23
+ return (
24
+ <DisclosureGroup isExpanded={open} onIsExpandedChange={setOpen} testID={testID}>
25
+ <DisclosureGroup.Label>
26
+ <Text modifiers={[foregroundStyle(labelColor)]}>{label}</Text>
27
+ </DisclosureGroup.Label>
28
+ {children}
29
+ </DisclosureGroup>
30
+ );
31
+ }
@@ -0,0 +1,41 @@
1
+ import './collapsible.css';
2
+ import type {SyntheticEvent} from 'react';
3
+ import type {CollapsibleProps} from './types';
4
+ import {Label} from '../typography';
5
+ import {useExpanded} from './shared';
6
+
7
+ /**
8
+ * On web the disclosure is a real `<details>` element: the header is its
9
+ * `<summary>` with a trailing chevron that rotates while open, mirroring the
10
+ * iOS `DisclosureGroup` row.
11
+ */
12
+ export function Collapsible({
13
+ label,
14
+ expanded,
15
+ defaultExpanded = false,
16
+ onExpandedChange,
17
+ children,
18
+ testID,
19
+ }: CollapsibleProps) {
20
+ const [open, setOpen] = useExpanded(expanded, defaultExpanded, onExpandedChange);
21
+ const onToggle = (event: SyntheticEvent<HTMLDetailsElement>) => {
22
+ const next = event.currentTarget.open;
23
+ if (next === open) return;
24
+ // Controlled: snap the DOM back to the prop so `expanded` stays the
25
+ // source of truth — React re-applies the parent's decision on the next
26
+ // render, or the element stays put when the change was ignored.
27
+ if (expanded !== undefined) event.currentTarget.open = open;
28
+ setOpen(next);
29
+ };
30
+ return (
31
+ <details className="ui-collapsible" open={open} onToggle={onToggle} data-testid={testID}>
32
+ <summary className="ui-collapsible__summary">
33
+ <Label color="label" style={{flexShrink: 1}}>{label}</Label>
34
+ <svg className="ui-collapsible__chevron" width="8" height="14" viewBox="0 0 8 14" fill="none" aria-hidden="true">
35
+ <path d="M1.5 1.5 L6.5 7 L1.5 12.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
36
+ </svg>
37
+ </summary>
38
+ <div className="ui-collapsible__content">{children}</div>
39
+ </details>
40
+ );
41
+ }
@@ -0,0 +1,21 @@
1
+ import {useCallback, useState} from 'react';
2
+
3
+ /**
4
+ * Bridges controlled and uncontrolled expansion, mirroring `useSelectedValue`.
5
+ */
6
+ export function useExpanded(
7
+ expanded: boolean | undefined,
8
+ defaultExpanded: boolean,
9
+ onChange: ((expanded: boolean) => void) | undefined,
10
+ ): [boolean, (next: boolean) => void] {
11
+ const [internal, setInternal] = useState(defaultExpanded);
12
+ const current = expanded ?? internal;
13
+ const setExpanded = useCallback(
14
+ (next: boolean) => {
15
+ if (expanded === undefined) setInternal(next);
16
+ onChange?.(next);
17
+ },
18
+ [expanded, onChange],
19
+ );
20
+ return [current, setExpanded];
21
+ }
@@ -0,0 +1,24 @@
1
+ import type {ReactNode} from 'react';
2
+
3
+ /**
4
+ * Cross-platform disclosure: a tappable header that shows or hides content.
5
+ *
6
+ * Bridges the SwiftUI `DisclosureGroup` on iOS, the Jetpack Compose
7
+ * expandable list item on Android (via `@expo/ui`'s universal `Collapsible`),
8
+ * and the HTML `<details>` element on web. May be used controlled
9
+ * (`expanded` + `onExpandedChange`) or uncontrolled (`defaultExpanded`).
10
+ */
11
+ export interface CollapsibleProps {
12
+ /** Text rendered in the tappable header. */
13
+ label: string;
14
+ /** Whether the content is shown (controlled). */
15
+ expanded?: boolean;
16
+ /** Initial state when uncontrolled. */
17
+ defaultExpanded?: boolean;
18
+ /** Called when the user toggles the header. */
19
+ onExpandedChange?: (expanded: boolean) => void;
20
+ /** Content shown while expanded. Must be native (`@expo/ui`) content on iOS/Android. */
21
+ children?: ReactNode;
22
+ /** Identifier used to locate the component in end-to-end tests. */
23
+ testID?: string;
24
+ }
@@ -0,0 +1,66 @@
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
+ }
@@ -0,0 +1,117 @@
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 {StyleSheet, useWindowDimensions, View} from 'react-native';
6
+ import {Image} from 'expo-image';
7
+ import {
8
+ Column,
9
+ Host,
10
+ ModalBottomSheet,
11
+ RNHostView,
12
+ Row,
13
+ Spacer,
14
+ Text,
15
+ useMaterialColors,
16
+ type ModalBottomSheetRef,
17
+ } from '@expo/ui/jetpack-compose';
18
+ import {alpha, clickable, fillMaxWidth, padding, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
19
+ import {ColorPickerSheet} from './sheet';
20
+ import {parseColor, ringSvg, svgDataUri, toCss, toHex, useColorValue, well} from './shared';
21
+
22
+ const RING = svgDataUri(ringSvg());
23
+ /** Horizontal inset of the sheet content (the `@expo/ui` `BottomSheet` default). */
24
+ const SHEET_INSET = 16;
25
+
26
+ /**
27
+ * Android redraws the iOS row: a Compose `Row` with the label and, hosted as
28
+ * a React Native view, the 28dp color well (rainbow ring, transparent gap,
29
+ * color swatch). Tapping the row opens the iOS picker redrawn in a Material
30
+ * `ModalBottomSheet`, fully expanded and with the sheet's own swipe gestures
31
+ * off so that dragging across the spectrum and sliders stays with the picker.
32
+ * The sheet lives in the same hosted subtree as the well, so the row stays a
33
+ * plain Compose child of its `Host`.
34
+ */
35
+ export function ColorPicker({
36
+ label,
37
+ value,
38
+ onValueChange,
39
+ supportsOpacity = true,
40
+ disabled,
41
+ testID,
42
+ }: ColorPickerProps) {
43
+ const colors = useMaterialColors();
44
+ const {width} = useWindowDimensions();
45
+ const [open, setOpen] = useState(false);
46
+ const [current, setCurrent] = useColorValue(value, onValueChange, supportsOpacity);
47
+ const modifiers: ModifierConfig[] = [fillMaxWidth()];
48
+ if (!disabled) modifiers.push(clickable(() => setOpen(true)));
49
+ if (testID) modifiers.push(testIDModifier(testID));
50
+
51
+ return (
52
+ <Row verticalAlignment="center" horizontalArrangement="spaceBetween" modifiers={modifiers}>
53
+ {label != null ? (
54
+ <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
55
+ ) : <Spacer/>}
56
+ <RNHostView matchContents modifiers={disabled ? [alpha(0.4)] : []}>
57
+ {/* `box-none` lets the tap fall through to the Compose row's `clickable`. */}
58
+ <View style={styles.well} pointerEvents="box-none" accessibilityLabel={`Selected color ${toHex(current, supportsOpacity)}`}>
59
+ <View style={styles.face} pointerEvents="none">
60
+ <Image source={{uri: RING}} style={StyleSheet.absoluteFill} contentFit="fill"/>
61
+ <View style={[styles.swatch, {backgroundColor: toCss(current)}]}/>
62
+ </View>
63
+ <PickerSheet open={open} onClose={() => setOpen(false)}>
64
+ <ColorPickerSheet
65
+ title={label ?? 'Colors'}
66
+ value={toHex(current, true)}
67
+ supportsOpacity={supportsOpacity}
68
+ onValueChange={hex => setCurrent(parseColor(hex))}
69
+ onClose={() => setOpen(false)}
70
+ width={width - SHEET_INSET * 2}
71
+ testID={testID ? `${testID}-sheet` : undefined}
72
+ />
73
+ </PickerSheet>
74
+ </View>
75
+ </RNHostView>
76
+ </Row>
77
+ );
78
+ }
79
+
80
+ /**
81
+ * The Material bottom sheet hosting the picker, mounted while `open` and
82
+ * unmounted after its hide animation (the `@expo/ui` `BottomSheet` pattern).
83
+ */
84
+ function PickerSheet({open, onClose, children}: React.PropsWithChildren<{open: boolean; onClose: () => void}>) {
85
+ const ref = useRef<ModalBottomSheetRef>(null);
86
+ const [mounted, setMounted] = useState(open);
87
+ if (open && !mounted) setMounted(true);
88
+ useEffect(() => {
89
+ if (open) return;
90
+ let cancelled = false;
91
+ ref.current?.hide().then(() => {
92
+ if (!cancelled) setMounted(false);
93
+ });
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, [open]);
98
+ if (!mounted) return null;
99
+ return (
100
+ <Host style={styles.sheetHost} pointerEvents="none">
101
+ <ModalBottomSheet ref={ref} onDismissRequest={onClose} skipPartiallyExpanded sheetGesturesEnabled={false}>
102
+ <Column modifiers={[padding(SHEET_INSET, 0, SHEET_INSET, 0)]}>
103
+ <RNHostView matchContents>{children as React.ReactElement}</RNHostView>
104
+ </Column>
105
+ </ModalBottomSheet>
106
+ </Host>
107
+ );
108
+ }
109
+
110
+ const inner = well.size - 2 * (well.ring + well.gap);
111
+
112
+ const styles = StyleSheet.create({
113
+ well: {width: well.size, height: well.size},
114
+ face: {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, alignItems: 'center', justifyContent: 'center'},
115
+ swatch: {width: inner, height: inner, borderRadius: inner / 2},
116
+ sheetHost: {position: 'absolute'},
117
+ });
@@ -0,0 +1,33 @@
1
+ import type {ColorPickerProps} from './types';
2
+ import type {ViewModifier} from '@expo/ui/swift-ui/modifiers';
3
+
4
+ import {ColorPicker as SwiftUIColorPicker} from '@expo/ui/swift-ui';
5
+ import {disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
6
+
7
+ /**
8
+ * iOS renders SwiftUI's `ColorPicker`: the label with the rainbow-ringed
9
+ * color well at the trailing edge, which opens the system color picker.
10
+ * SwiftUI reports the selection as `#RRGGBBAA` when opacity is supported and
11
+ * `#RRGGBB` otherwise; the other platforms mirror both formats.
12
+ */
13
+ export function ColorPicker({
14
+ label,
15
+ value,
16
+ onValueChange,
17
+ supportsOpacity = true,
18
+ disabled,
19
+ testID,
20
+ }: ColorPickerProps) {
21
+ const modifiers: ViewModifier[] = [];
22
+ if (disabled) modifiers.push(disabledMod(true));
23
+ return (
24
+ <SwiftUIColorPicker
25
+ label={label}
26
+ selection={value}
27
+ supportsOpacity={supportsOpacity}
28
+ onSelectionChange={onValueChange}
29
+ modifiers={modifiers}
30
+ testID={testID}
31
+ />
32
+ );
33
+ }
@@ -0,0 +1,62 @@
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`.
17
+ */
18
+ export function ColorPicker({
19
+ label,
20
+ value,
21
+ onValueChange,
22
+ supportsOpacity = true,
23
+ disabled,
24
+ testID,
25
+ style,
26
+ }: ColorPickerProps) {
27
+ const [open, setOpen] = useState(false);
28
+ const [current, setCurrent] = useColorValue(value, onValueChange, supportsOpacity);
29
+ const vars = {
30
+ '--ui-color-picker-value': toCss(current),
31
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
32
+ } as CSSProperties;
33
+ return (
34
+ <>
35
+ <button
36
+ type="button"
37
+ className={['ui-color-picker', disabled && 'ui-color-picker--disabled'].filter(Boolean).join(' ')}
38
+ style={vars}
39
+ disabled={disabled}
40
+ aria-label={label == null ? 'Color' : undefined}
41
+ aria-haspopup="dialog"
42
+ aria-expanded={open}
43
+ onClick={() => setOpen(true)}
44
+ data-testid={testID}>
45
+ {label != null ? <Label color="label" style={{flexShrink: 1}}>{label}</Label> : null}
46
+ <span className="ui-color-picker__well" aria-hidden="true">
47
+ <span className="ui-color-picker__swatch"/>
48
+ </span>
49
+ </button>
50
+ <Sheet isPresented={open} onDismiss={() => setOpen(false)}>
51
+ <ColorPickerSheet
52
+ title={label ?? 'Colors'}
53
+ value={toHex(current, true)}
54
+ supportsOpacity={supportsOpacity}
55
+ onValueChange={hex => setCurrent(parseColor(hex))}
56
+ onClose={() => setOpen(false)}
57
+ testID={testID ? `${testID}-sheet` : undefined}
58
+ />
59
+ </Sheet>
60
+ </>
61
+ );
62
+ }
@@ -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
+ }