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.
package/README.md CHANGED
@@ -107,17 +107,18 @@ positioning and `<dialog>`.
107
107
  | [TextField](src/text-field/types.ts) | Single or multiline text input with keyboard type, capitalization and secure entry | ✓ | ✓ | ✓ |
108
108
  | [Switch](src/switch/types.ts) | On/off toggle with a leading label | ✓ | ✓ | ✓ |
109
109
  | [Checkbox](src/checkbox/types.ts) | Checked/unchecked box with a leading label | ✓ | ✓ | ✓ |
110
+ | [ColorPicker](src/color-picker/types.ts) | Label with a rainbow-ringed color well that opens the iOS-style color picker (Grid, Spectrum, Sliders, opacity) | ✓ | ✓ | ✓ |
110
111
  | [Slider](src/slider/types.ts) | Thumb dragged along a continuous or stepped range | ✓ | ✓ | ✓ |
111
112
  | [Stepper](src/stepper/types.ts) | Number adjusted with increment and decrement buttons | ✓ | ✓ | ✓ |
112
113
  | [Picker](src/picker/types.ts) | Dropdown that selects one option from a list | ✓ | ✓ | ✓ |
113
114
  | [SegmentedControl](src/segmented/types.ts) | Row of segments that selects one option | ✓ | ✓ | ✓ |
114
115
  | [DateTimePicker](src/date-time/types.ts) | Picks a date, a time or both, with optional bounds | ✓ | ✓ | ✓ |
115
116
  | [Progress](src/progress/types.ts) | Linear bar or circular ring, determinate or indeterminate | ✓ | ✓ | ✓ |
117
+ | [Gauge](src/gauge/types.ts) | Value within a range in the SwiftUI gauge styles: capacity bars, marker bar, open or closed ring | ✓ | ✓ | ✓ |
116
118
  | [Menu](src/menu/types.ts) | Dropdown menu of actions opened from a button | ✓ | ✓ | ✓ |
117
119
  | [ContextMenu](src/menu/types.ts) | Menu of actions opened by long-pressing (or right-clicking) its content | ✓ | ✓ | ✓ |
118
120
  | [Tooltip](src/tooltip/types.ts) | Short hint shown on hover, focus or long-press; an accessibility hint on iOS | | ✓ | ✓ |
119
121
  | [Alert](src/alert/types.ts) | Modal dialog or action sheet with a title, message and actions | ✓ | ✓ | ✓ |
120
- | [QRCode](src/qr/index.tsx) | Renders a value as a QR code image | ✓ | ✓ | ✓ |
121
122
  | [ExternalLink](src/router/external-link.tsx) | Link that opens in an in-app browser on native and a new tab on web | ✓ | ✓ | ✓ |
122
123
  | [Typography](src/typography/types.ts) | Text in the platform type scale, with `Title`, `Body`, `Caption` and other variants as shortcuts | ✓ | ✓ | ✓ |
123
124
 
@@ -242,27 +243,37 @@ package from `../src`, so it doubles as the development harness.
242
243
  bun install # bun >= 1.4
243
244
  bun run web # or ios, android
244
245
  bun run typecheck # package, example and storybook
245
- bun run lint # eslint (eslint-config-expo)
246
+ bun run lint # oxlint
246
247
  bun run test # vitest, once per platform (ios, android, web)
247
248
  bun run test:ui # vitest watch mode with the browser UI
248
249
  ```
249
250
 
250
251
  ### Storybook
251
252
 
252
- Stories live next to each component (`src/<name>/<name>.stories.tsx`) and run
253
- in the [storybook](./storybook) app, an Expo project built on
254
- `@storybook/react-native`, so every story renders the real SwiftUI, Compose
255
- or DOM control through Metro.
253
+ Stories live next to each component (`src/<name>/<name>.stories.tsx`) and are
254
+ shared by two Storybooks in the [storybook](./storybook) workspace:
255
+
256
+ - **Web** (`storybook/.storybook`): `@storybook/react-native-web-vite`, so the
257
+ site has the full Storybook manager — MDX guides in `storybook/docs/`, an
258
+ autogenerated docs page with a props table per component, controls, a
259
+ black/white theme that follows the OS scheme and a toolbar to force light or
260
+ dark. This is the documentation site published to GitHub Pages.
261
+ - **On-device** (`storybook/.rnstorybook`): `@storybook/react-native` through
262
+ Metro, rendering the real SwiftUI and Compose controls on iOS and Android.
256
263
 
257
264
  ```sh
258
- bun run storybook:web # storybook in the browser
265
+ bun run storybook:web # web storybook + docs site (Vite)
259
266
  bun run storybook:ios # or storybook:android — on-device storybook
260
- bun run storybook:build # static web export in storybook/dist
267
+ bun run storybook:build # static web build in storybook/dist
268
+ bun run storybook:test # every story as a Vitest test in headless Chromium
261
269
  ```
262
270
 
263
- The global decorator (`storybook/.rnstorybook/preview.tsx`) wraps stories in
271
+ The shared decorator (`storybook/src/frame.tsx`) wraps stories in
264
272
  `AccentProvider` and an accent-seeded `@expo/ui` `Host`. Stories built from
265
- plain React Native views opt out with `parameters: {native: false}`.
273
+ plain React Native views opt out with `parameters: {native: false}`; a story
274
+ can pick an accent with `parameters: {accent: '#8959EA'}`. The web build runs
275
+ the kit through Vite with the same Metro-compat plugin as the Vitest web
276
+ project (`vitest/metro-compat.ts`).
266
277
 
267
278
  ### Tests
268
279
 
@@ -285,6 +296,16 @@ props are the payload sent to SwiftUI/Compose, and `src/__tests__/native.ts`
285
296
  has helpers to assert on them. `bun run test:coverage` writes an interactive
286
297
  HTML test report to `test-report/` and coverage to `coverage/`.
287
298
 
299
+ The stories double as tests: `@storybook/addon-vitest`
300
+ (`storybook/vitest.config.mts`) renders every web story in headless Chromium
301
+ through Vitest browser mode, so a story that throws while mounting fails the
302
+ run, and `@storybook/addon-a11y` runs axe-core on each rendered story as part
303
+ of it (`parameters.a11y.test: 'error'`; the `color-contrast` rule is off
304
+ because the palette follows the iOS system colors). `bun run storybook:test` runs them from the CLI (Playwright's Chromium
305
+ must be installed: `bunx playwright install chromium` in `storybook/`); in
306
+ `storybook:web` the testing widget at the bottom of the sidebar runs them
307
+ live.
308
+
288
309
  ### CI
289
310
 
290
311
  GitHub Actions ([.github/workflows](./.github/workflows)):
@@ -293,7 +314,8 @@ GitHub Actions ([.github/workflows](./.github/workflows)):
293
314
  (HTML report and coverage as artifacts), a Metro export of the example app
294
315
  for ios, android and web (proves every platform file and
295
316
  `@expo/material-symbols` asset resolves without Xcode or Gradle), and a web
296
- Storybook build uploaded as an artifact.
317
+ Storybook build uploaded as an artifact after its stories pass as Vitest
318
+ browser tests.
297
319
  - **Storybook** (`storybook.yml`) — on push to `master`: publishes the web
298
320
  Storybook to GitHub Pages, with the Vitest HTML report at `/tests` and
299
321
  coverage at `/coverage`. Enable Pages with the "GitHub Actions" source in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-interface",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Native-first universal UI kit for Expo — SwiftUI on iOS, Jetpack Compose on Android, real DOM on web — built on @expo/ui.",
5
5
  "license": "MIT",
6
6
  "author": "kat-tax",
@@ -48,7 +48,7 @@
48
48
  ],
49
49
  "scripts": {
50
50
  "typecheck": "tsc --noEmit && bun run --cwd example typecheck && bun run --cwd storybook typecheck",
51
- "lint": "eslint . --max-warnings 0",
51
+ "lint": "oxlint --max-warnings 0",
52
52
  "test": "vitest run",
53
53
  "test:watch": "vitest",
54
54
  "test:coverage": "vitest run --coverage",
@@ -61,11 +61,9 @@
61
61
  "storybook:ios": "bun run --cwd storybook ios",
62
62
  "storybook:android": "bun run --cwd storybook android",
63
63
  "storybook:build": "bun run --cwd storybook build",
64
+ "storybook:test": "bun run --cwd storybook test",
64
65
  "test:ui": "vitest --ui"
65
66
  },
66
- "dependencies": {
67
- "qrcode-generator": "^2.0.4"
68
- },
69
67
  "peerDependencies": {
70
68
  "@expo/material-symbols": "*",
71
69
  "@expo/ui": "^57.0.0",
@@ -91,13 +89,10 @@
91
89
  "@testing-library/react": "^16.3.3",
92
90
  "@testing-library/react-native": "^14.0.1",
93
91
  "@types/react": "~19.2.2",
94
- "@typescript-eslint/parser": "8.59.0",
95
92
  "@vitest/coverage-v8": "^4.1.11",
96
93
  "@vitest/ui": "^4.1.11",
97
- "eslint": "^9.39.5",
98
- "eslint-config-expo": "~57.0.2",
99
- "globals": "^16.0.0",
100
94
  "jsdom": "^30.0.1",
95
+ "oxlint": "^1.81.0",
101
96
  "test-renderer": "^1.2.0",
102
97
  "typescript": "~6.0.3",
103
98
  "vite": "^8.2.2",
@@ -23,9 +23,9 @@
23
23
  .ui-alert__body {
24
24
  display: flex;
25
25
  flex-direction: column;
26
- align-items: center;
27
- gap: 4px;
28
- text-align: center;
26
+ align-items: flex-start;
27
+ gap: 16px;
28
+ text-align: left;
29
29
  }
30
30
 
31
31
  .ui-alert__actions {
@@ -17,8 +17,7 @@ export function Alert({title, message, visible, onDismiss, actions = DEFAULT_ACT
17
17
  const {cancel, others} = splitActions(actions);
18
18
 
19
19
  useEffect(() => {
20
- const dialog = ref.current;
21
- if (!dialog) return;
20
+ const dialog = ref.current!;
22
21
  if (visible && !dialog.open) dialog.showModal();
23
22
  else if (!visible && dialog.open) dialog.close();
24
23
  }, [visible]);
@@ -6,6 +6,8 @@
6
6
  display: inline-flex;
7
7
  align-items: center;
8
8
  justify-content: center;
9
+ /* Hug the content even inside a flex column that stretches its children. */
10
+ width: fit-content;
9
11
  border-radius: 999px;
10
12
  border: 1px solid transparent;
11
13
  padding: 10px 18px;
@@ -90,6 +92,10 @@
90
92
  padding-inline: 14px;
91
93
  }
92
94
 
95
+ .ui-button--fill {
96
+ width: 100%;
97
+ }
98
+
93
99
  .ui-button--destructive {
94
100
  --ui-button-accent: var(--color-destructive);
95
101
  --ui-button-on-accent: var(--color-on-destructive);
@@ -1,5 +1,5 @@
1
1
  import type {ButtonProps, ButtonShape, ButtonVariant} from './types';
2
- import {testID as testIDModifier, width} from '@expo/ui/jetpack-compose/modifiers';
2
+ import {fillMaxWidth, testID as testIDModifier, width, wrapContentHeight, wrapContentWidth} from '@expo/ui/jetpack-compose/modifiers';
3
3
  import {Button as ComposeButton, OutlinedButton, TextButton, Icon, IconButton, FilledIconButton, OutlinedIconButton, Spacer, Shape, Text} from '@expo/ui/jetpack-compose';
4
4
  import {SIZE_ICON, SIZE_TEXT, androidContentPadding} from './shared';
5
5
  import {onAccent as contrastOf} from '../accent';
@@ -47,6 +47,7 @@ export function Button({
47
47
  suffixIcon,
48
48
  hideLabel = false,
49
49
  disabled,
50
+ fillWidth = false,
50
51
  testID,
51
52
  }: ButtonProps) {
52
53
  const themeTint = useColor('tint');
@@ -63,7 +64,13 @@ export function Button({
63
64
  const iconSize = SIZE_ICON[size];
64
65
  const textSize = SIZE_TEXT[size];
65
66
  const resolvedShape = resolveShape(shape);
66
- const modifiers = testID ? [testIDModifier(testID)] : [];
67
+ // A `Host` measures a direct child with its own (tight) constraints, which
68
+ // a Compose button would fill on both axes; wrap to the content instead.
69
+ const modifiers = [
70
+ fillWidth ? fillMaxWidth() : wrapContentWidth('start'),
71
+ wrapContentHeight('top'),
72
+ ];
73
+ if (testID) modifiers.push(testIDModifier(testID));
67
74
  const iconOnly = hideLabel && !!prefixIcon?.drawable;
68
75
 
69
76
  if (iconOnly) {
@@ -4,6 +4,7 @@ import {Button as SwiftUIButton, HStack, Image, Text} from '@expo/ui/swift-ui';
4
4
  import {buttonStyle, buttonBorderShape, controlSize, labelStyle, tint, disabled as disabledMod} from '@expo/ui/swift-ui/modifiers';
5
5
  import {ICON_GAP, SIZE_ICON, iosSymbol, swiftBorderShape, swiftControlSize} from './shared';
6
6
  import {onAccent as contrastOf} from '../accent';
7
+ import {fillWidth as fillWidthModifiers} from '../fill';
7
8
  import {useColor} from '../theme';
8
9
 
9
10
  const VARIANT_STYLE: Record<ButtonVariant, 'borderedProminent' | 'bordered' | 'plain'> = {
@@ -29,6 +30,7 @@ export function Button({
29
30
  suffixIcon,
30
31
  hideLabel = false,
31
32
  disabled,
33
+ fillWidth = false,
32
34
  testID,
33
35
  }: ButtonProps) {
34
36
  const themeTint = useColor('tint');
@@ -49,17 +51,20 @@ export function Button({
49
51
  if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
50
52
  if (disabled) modifiers.push(disabledMod(true));
51
53
  if (iconOnly) modifiers.push(labelStyle('iconOnly'));
52
- if (hasSuffix) {
54
+ // SwiftUI has no trailing-icon `Label`, and a bordered style only paints
55
+ // behind the label, so both cases compose the label by hand: the frame
56
+ // that fills the width goes on the label, not the button.
57
+ if ((hasSuffix || fillWidth) && !iconOnly) {
53
58
  return (
54
59
  <SwiftUIButton
55
60
  role={role === 'destructive' ? 'destructive' : 'default'}
56
61
  onPress={onPress}
57
62
  modifiers={modifiers}
58
63
  testID={testID}>
59
- <HStack spacing={ICON_GAP}>
64
+ <HStack spacing={ICON_GAP} modifiers={fillWidth ? fillWidthModifiers : undefined}>
60
65
  {prefixIcon ? <Image systemName={iosSymbol(prefixIcon)} color={iconColor} size={SIZE_ICON[size]}/> : null}
61
66
  <Text>{label}</Text>
62
- <Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/>
67
+ {hasSuffix ? <Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/> : null}
63
68
  </HStack>
64
69
  </SwiftUIButton>
65
70
  );
@@ -35,6 +35,7 @@ export function Button({
35
35
  suffixIcon,
36
36
  hideLabel = false,
37
37
  disabled = false,
38
+ fillWidth = false,
38
39
  testID,
39
40
  popoverTarget,
40
41
  popoverTargetAction,
@@ -58,6 +59,7 @@ export function Button({
58
59
  !shape && 'ui-button--pill',
59
60
  iconOnly && 'ui-button--icon-only',
60
61
  role === 'destructive' && 'ui-button--destructive',
62
+ fillWidth && 'ui-button--fill',
61
63
  ].filter(Boolean).join(' ');
62
64
 
63
65
  return (
@@ -67,6 +67,13 @@ export interface ButtonProps {
67
67
  hideLabel?: boolean;
68
68
  /** Disables interaction and dims the button. */
69
69
  disabled?: boolean;
70
+ /**
71
+ * Stretch the button to its container's full width. By default the button
72
+ * hugs its content, even when the parent would stretch it (a flex column on
73
+ * web, a `Host` on Android).
74
+ * @default false
75
+ */
76
+ fillWidth?: boolean;
70
77
  /** Identifier used to locate the component in end-to-end tests. */
71
78
  testID?: string;
72
79
  }
@@ -22,7 +22,7 @@ export function Checkbox({
22
22
  }: CheckboxProps) {
23
23
  const vars = {
24
24
  ...(accentColor ? {'--ui-checkbox-accent': accentColor} : null),
25
- ...flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined),
25
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
26
26
  } as CSSProperties;
27
27
  const input = (
28
28
  <input
@@ -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
+ }