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 +33 -11
- package/package.json +4 -9
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +9 -2
- package/src/button/index.ios.tsx +8 -3
- package/src/button/index.tsx +2 -0
- package/src/button/types.ts +7 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +66 -0
- package/src/color-picker/index.android.tsx +117 -0
- package/src/color-picker/index.ios.tsx +33 -0
- package/src/color-picker/index.tsx +62 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +34 -0
- package/src/field-group/field-group.css +23 -7
- package/src/field-group/index.android.tsx +22 -22
- package/src/field-group/index.web.tsx +2 -2
- package/src/gauge/gauge.css +200 -0
- package/src/gauge/index.android.tsx +212 -0
- package/src/gauge/index.ios.tsx +40 -0
- package/src/gauge/index.tsx +165 -0
- package/src/gauge/shared.ts +79 -0
- package/src/gauge/types.ts +64 -0
- package/src/index.ts +4 -1
- package/src/progress/progress.css +9 -0
- package/src/screen/header.tsx +1 -0
- package/src/segmented/index.tsx +1 -1
- package/src/segmented/segmented.css +6 -7
- package/src/slider/index.tsx +1 -1
- package/src/stepper/index.tsx +1 -1
- package/src/switch/index.tsx +2 -0
- package/src/qr/index.tsx +0 -26
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 #
|
|
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
|
|
253
|
-
in the [storybook](./storybook)
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": "
|
|
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",
|
package/src/alert/alert.css
CHANGED
package/src/alert/index.tsx
CHANGED
|
@@ -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]);
|
package/src/button/button.css
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/button/index.ios.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|
package/src/button/index.tsx
CHANGED
|
@@ -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 (
|
package/src/button/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/checkbox/index.tsx
CHANGED
|
@@ -22,7 +22,7 @@ export function Checkbox({
|
|
|
22
22
|
}: CheckboxProps) {
|
|
23
23
|
const vars = {
|
|
24
24
|
...(accentColor ? {'--ui-checkbox-accent': accentColor} : null),
|
|
25
|
-
...flatten(
|
|
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
|
+
}
|