expo-interface 0.2.0 → 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 (58) hide show
  1. package/README.md +87 -10
  2. package/package.json +6 -1
  3. package/src/accent.tsx +21 -8
  4. package/src/button/button.css +6 -0
  5. package/src/button/index.android.tsx +5 -1
  6. package/src/button/index.ios.tsx +5 -1
  7. package/src/button/index.tsx +6 -1
  8. package/src/button/types.ts +13 -0
  9. package/src/color-picker/color-picker.css +70 -0
  10. package/src/color-picker/index.android.tsx +77 -49
  11. package/src/color-picker/index.ios.tsx +58 -11
  12. package/src/color-picker/index.tsx +65 -15
  13. package/src/color-picker/types.ts +9 -3
  14. package/src/context-menu/index.android.tsx +39 -11
  15. package/src/context-menu/index.ios.tsx +3 -1
  16. package/src/context-menu/index.tsx +24 -7
  17. package/src/fab/fab.css +72 -0
  18. package/src/fab/index.android.tsx +72 -0
  19. package/src/fab/index.ios.tsx +68 -0
  20. package/src/fab/index.tsx +37 -0
  21. package/src/fab/shared.ts +23 -0
  22. package/src/fab/types.ts +39 -0
  23. package/src/field-group/index.android.tsx +22 -16
  24. package/src/field-group/index.tsx +36 -5
  25. package/src/field-group/index.web.tsx +25 -9
  26. package/src/field-group/shared.tsx +49 -0
  27. package/src/field-group/types.ts +25 -0
  28. package/src/header-menu/index.tsx +76 -0
  29. package/src/host/index.tsx +32 -0
  30. package/src/index.ts +31 -5
  31. package/src/keyboard/index.tsx +81 -0
  32. package/src/keyboard/library.native.ts +17 -0
  33. package/src/keyboard/library.ts +12 -0
  34. package/src/keyboard/types.ts +20 -0
  35. package/src/list-item/index.android.tsx +23 -6
  36. package/src/list-item/index.tsx +20 -4
  37. package/src/list-item/index.web.tsx +60 -0
  38. package/src/list-item/list-item.css +65 -0
  39. package/src/list-item/types.ts +21 -0
  40. package/src/menu/index.android.tsx +22 -9
  41. package/src/menu/index.ios.tsx +26 -11
  42. package/src/menu/index.tsx +26 -10
  43. package/src/menu/list.tsx +25 -10
  44. package/src/menu/menu.css +52 -0
  45. package/src/menu/types.ts +41 -2
  46. package/src/scheme.ts +140 -0
  47. package/src/screen/index.tsx +49 -5
  48. package/src/tab-stack/index.tsx +15 -2
  49. package/src/tabs/index.tsx +2 -1
  50. package/src/tabs/index.web.tsx +29 -4
  51. package/src/tabs/types.ts +20 -2
  52. package/src/text-field/index.android.tsx +30 -6
  53. package/src/text-field/index.ios.tsx +16 -3
  54. package/src/text-field/index.tsx +24 -4
  55. package/src/text-field/inline.tsx +87 -0
  56. package/src/text-field/shared.ts +40 -1
  57. package/src/text-field/types.ts +47 -2
  58. package/src/theme.ts +44 -5
package/README.md CHANGED
@@ -41,12 +41,14 @@ Peer dependencies and Android icon setup are covered in
41
41
  }
42
42
  ```
43
43
 
44
- 2. On web, emit the palette as CSS variables in the root HTML.
44
+ 2. On web, emit the palette as CSS variables in the root HTML, and the boot
45
+ script that applies a scheme the user forced (see `setColorScheme`) before
46
+ the bundle runs.
45
47
 
46
48
  ```tsx
47
49
  // app/+html.tsx
48
50
  import {ScrollViewStyleReset} from 'expo-router/html';
49
- import {getThemeCSS} from 'expo-interface';
51
+ import {getThemeBootScript, getThemeCSS} from 'expo-interface';
50
52
 
51
53
  export default function Root({children}: React.PropsWithChildren) {
52
54
  return (
@@ -55,6 +57,7 @@ Peer dependencies and Android icon setup are covered in
55
57
  <meta charSet="utf-8"/>
56
58
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
57
59
  <style dangerouslySetInnerHTML={{__html: getThemeCSS()}}/>
60
+ <script dangerouslySetInnerHTML={{__html: getThemeBootScript()}}/>
58
61
  <ScrollViewStyleReset/>
59
62
  </head>
60
63
  <body>{children}</body>
@@ -93,21 +96,25 @@ positioning and `<dialog>`.
93
96
 
94
97
  | Component | Description | iOS | Android | Web |
95
98
  | --- | --- | :-: | :-: | :-: |
96
- | [Screen](src/screen/index.tsx) | Screen container that handles safe areas, status bar, background and content width, optionally hosting native content | ✓ | ✓ | ✓ |
99
+ | [Screen](src/screen/index.tsx) | Screen container that handles safe areas, status bar, background and content width, optionally hosting native content and placing a floating action button | ✓ | ✓ | ✓ |
97
100
  | [ScreenHeader](src/screen/header.tsx) | Simple header bar with an optional back button and a trailing slot | ✓ | ✓ | ✓ |
98
- | [Tabs](src/tabs/types.ts) | Tab bar for `expo-router`: native tabs on iOS and Android, a floating top bar with a logo on web | ✓ | ✓ | ✓ |
99
- | [TabStack](src/tab-stack/index.tsx) | Preconfigured `expo-router` stack for the root screen of a tab | ✓ | ✓ | ✓ |
101
+ | [HeaderMenu](src/header-menu/index.tsx) | Menu for a stack header's trailing slot; survives Android's header re-parenting on a tab switch | ✓ | ✓ | ✓ |
102
+ | [NativeHost](src/host/index.tsx) | Accent-seeded `@expo/ui` host for controls that sit inside a React Native layout | ✓ | ✓ | ✓ |
103
+ | [KeyboardBar](src/keyboard/index.tsx) | Bottom bar that sticks to the keyboard and reports its height (`react-native-keyboard-controller`, optional) | ✓ | ✓ | ✓ |
104
+ | [Tabs](src/tabs/types.ts) | Tab bar for `expo-router`: native tabs on iOS and Android, a floating top bar with a logo and action slots on web; can be hidden | ✓ | ✓ | ✓ |
105
+ | [TabStack](src/tab-stack/index.tsx) | Preconfigured `expo-router` stack for the root screen of a tab, with a header trailing slot | ✓ | ✓ | ✓ |
100
106
  | [ConstrainedStackHeader](src/stack-header/index.tsx) | Stack header that matches the content max-width on web | | | ✓ |
101
107
  | [Sheet](src/sheet/index.tsx) | Bottom sheet that inherits the accent color | ✓ | ✓ | ✓ |
102
- | [FieldGroup](src/field-group/index.tsx) | Scrollable settings form made of titled sections of rows | ✓ | ✓ | ✓ |
103
- | [ListItem](src/list-item/types.ts) | Tappable row with leading, trailing and supporting text slots | ✓ | ✓ | ✓ |
108
+ | [FieldGroup](src/field-group/types.ts) | Scrollable settings form made of titled sections of rows, each with an optional footer note | ✓ | ✓ | ✓ |
109
+ | [ListItem](src/list-item/types.ts) | Tappable row with leading, trailing and supporting text slots and an optional trailing text action | ✓ | ✓ | ✓ |
110
+ | [Fab](src/fab/types.ts) | Floating action button: Material 3 on Android, drawn in SwiftUI on iOS, a DOM button on web; can open a menu | ✓ | ✓ | ✓ |
104
111
  | [Collapsible](src/collapsible/types.ts) | Row that expands and collapses its content | ✓ | ✓ | ✓ |
105
112
  | [Divider](src/divider/types.ts) | Horizontal or vertical hairline separator | ✓ | ✓ | ✓ |
106
113
  | [Button](src/button/types.ts) | Filled, outlined or text button with optional icons, sizes, shapes and a destructive role | ✓ | ✓ | ✓ |
107
114
  | [TextField](src/text-field/types.ts) | Single or multiline text input with keyboard type, capitalization and secure entry | ✓ | ✓ | ✓ |
108
115
  | [Switch](src/switch/types.ts) | On/off toggle with a leading label | ✓ | ✓ | ✓ |
109
116
  | [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) | ✓ | ✓ | ✓ |
117
+ | [ColorPicker](src/color-picker/types.ts) | Label with a color well that opens the iOS-style color picker (Grid, Spectrum, Sliders, opacity), optionally with preset swatches | ✓ | ✓ | ✓ |
111
118
  | [Slider](src/slider/types.ts) | Thumb dragged along a continuous or stepped range | ✓ | ✓ | ✓ |
112
119
  | [Stepper](src/stepper/types.ts) | Number adjusted with increment and decrement buttons | ✓ | ✓ | ✓ |
113
120
  | [Picker](src/picker/types.ts) | Dropdown that selects one option from a list | ✓ | ✓ | ✓ |
@@ -115,8 +122,8 @@ positioning and `<dialog>`.
115
122
  | [DateTimePicker](src/date-time/types.ts) | Picks a date, a time or both, with optional bounds | ✓ | ✓ | ✓ |
116
123
  | [Progress](src/progress/types.ts) | Linear bar or circular ring, determinate or indeterminate | ✓ | ✓ | ✓ |
117
124
  | [Gauge](src/gauge/types.ts) | Value within a range in the SwiftUI gauge styles: capacity bars, marker bar, open or closed ring | ✓ | ✓ | ✓ |
118
- | [Menu](src/menu/types.ts) | Dropdown menu of actions opened from a button | ✓ | ✓ | ✓ |
119
- | [ContextMenu](src/menu/types.ts) | Menu of actions opened by long-pressing (or right-clicking) its content | ✓ | ✓ | ✓ |
125
+ | [Menu](src/menu/types.ts) | Dropdown menu of actions opened from a button (or a text link on web); items can be checked or carry a color swatch | ✓ | ✓ | ✓ |
126
+ | [ContextMenu](src/menu/types.ts) | Menu of actions opened by long-pressing (or right-clicking) its content, or at a point the content reports | ✓ | ✓ | ✓ |
120
127
  | [Tooltip](src/tooltip/types.ts) | Short hint shown on hover, focus or long-press; an accessibility hint on iOS | | ✓ | ✓ |
121
128
  | [Alert](src/alert/types.ts) | Modal dialog or action sheet with a title, message and actions | ✓ | ✓ | ✓ |
122
129
  | [ExternalLink](src/router/external-link.tsx) | Link that opens in an in-app browser on native and a new tab on web | ✓ | ✓ | ✓ |
@@ -202,16 +209,86 @@ import {theme} from 'expo-interface';
202
209
  | `destructive` | Delete buttons, failed states and other destructive actions |
203
210
  | `onDestructive` | Text and icons drawn on top of `destructive` |
204
211
 
212
+ #### `usePalette`
213
+
214
+ Returns the resolved palette of the current scheme as plain color strings on
215
+ every platform, with the live accent as `tint`. `useColor` stays the right
216
+ call for styles (on web it hands out the CSS variable, which follows the
217
+ scheme without a re-render); `usePalette` is for canvases, native views and
218
+ anything else that cannot read a variable:
219
+
220
+ ```tsx
221
+ const palette = usePalette();
222
+ canvas.setTheme({background: palette.background, text: palette.label, link: palette.tint});
223
+ ```
224
+
225
+ #### Color scheme
226
+
227
+ `useColorScheme()` answers `'light'` or `'dark'` from one stable subscription
228
+ (React Native's own hook re-subscribes on every render and, on web, can miss
229
+ the `matchMedia` event when an ancestor re-renders during it). Every kit
230
+ component follows it.
231
+
232
+ `setColorScheme('system' | 'light' | 'dark')` forces a scheme or follows the
233
+ system again: `Appearance.setColorScheme` natively; on web (which has no such
234
+ call) the palette of the forced scheme is written on the root element along
235
+ with `color-scheme` and `data-theme`, every `Appearance` listener hears the
236
+ change, and the choice is saved in `localStorage` so `getThemeBootScript()`
237
+ in `+html.tsx` applies it before the bundle runs. `getThemeCSS()` carries the
238
+ matching `:root[data-theme]` palettes.
239
+
240
+ ```tsx
241
+ <SegmentedControl label="Theme" selectedValue={mode} onValueChange={mode => {
242
+ setMode(mode);
243
+ setColorScheme(mode);
244
+ }}>
245
+ ```
246
+
205
247
  #### Other exports
206
248
 
207
249
  | Export | Purpose |
208
250
  | --- | --- |
209
251
  | `useNavTheme()` | React Navigation theme built from the palette and accent |
210
252
  | `getThemeCSS()` | Palette as CSS variables, for `+html.tsx` |
253
+ | `getThemeBootScript()` | Script applying a saved forced scheme before the bundle runs, for `+html.tsx` |
254
+ | `useColorScheme()`, `setColorScheme()` | The scheme as a store, and forcing it |
255
+ | `usePalette()` | Resolved palette as plain colors |
211
256
  | `colors` | Raw light and dark palettes |
212
257
  | `spacing`, `bound`, `inset` | Layout constants |
213
258
  | `fonts`, `fontWeights`, `variants` | Type constants |
214
259
 
260
+ ### Keyboard
261
+
262
+ `KeyboardBar` is a bottom bar that sticks to the keyboard by a transform,
263
+ never a resize, and reports the keyboard's height through `onKeyboard` so the
264
+ content above it can pad or scroll by that much. It needs
265
+ `react-native-keyboard-controller`, an optional peer the kit loads only
266
+ natively (the library's Reanimated cannot render on the server, so nothing of
267
+ it reaches the web bundle, where the bar is a plain view); `AccentProvider`
268
+ mounts its `KeyboardProvider` when the library is installed.
269
+
270
+ ```sh
271
+ npx expo install react-native-keyboard-controller
272
+ ```
273
+
274
+ `TextField` takes `returnKeyType` and `submitBehavior` for the keyboard's
275
+ action key, and its `inline` variant is a borderless React Native input for a
276
+ field inside a React Native layout, which focuses on mount with `autoFocus`
277
+ and makes sure the keyboard came on Android.
278
+
279
+ ### Menus and headers
280
+
281
+ `Menu` items take `active` (a check mark) and `swatch` (a color dot);
282
+ `ContextMenu` opens at a point its content reports through `at`. On web
283
+ `Menu` renders a text link with `trigger="link"`, for a bar. `HeaderMenu` is
284
+ the menu for a stack header's trailing slot (`TabStack`'s `headerRight`): on
285
+ Android the native stack re-parents the header's views on a tab switch, which
286
+ a Compose view refuses, so its host is rebuilt on every focus change.
287
+
288
+ ```tsx
289
+ <TabStack title="Documents" headerRight={() => <HeaderMenu label="New…" icon={icon.add} items={items}/>}/>
290
+ ```
291
+
215
292
  ## Install details
216
293
 
217
294
  The components are built on standard Expo modules, which are peer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-interface",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -29,6 +29,7 @@
29
29
  "sideEffects": [
30
30
  "**/*.css",
31
31
  "src/theme.ts",
32
+ "src/scheme.ts",
32
33
  "src/screen/index.tsx"
33
34
  ],
34
35
  "files": [
@@ -77,11 +78,15 @@
77
78
  "expo-web-browser": "*",
78
79
  "react": "*",
79
80
  "react-native": "*",
81
+ "react-native-keyboard-controller": "*",
80
82
  "react-native-safe-area-context": "*"
81
83
  },
82
84
  "peerDependenciesMeta": {
83
85
  "@expo/material-symbols": {
84
86
  "optional": true
87
+ },
88
+ "react-native-keyboard-controller": {
89
+ "optional": true
85
90
  }
86
91
  },
87
92
  "devDependencies": {
package/src/accent.tsx CHANGED
@@ -1,6 +1,7 @@
1
- import type {PropsWithChildren} from 'react';
2
- import {createContext, useContext, useEffect} from 'react';
1
+ import type {ComponentType, PropsWithChildren} from 'react';
2
+ import {createContext, Fragment, useContext, useEffect} from 'react';
3
3
  import {Platform} from 'react-native';
4
+ import {loadKeyboardController} from './keyboard/library';
4
5
 
5
6
  /**
6
7
  * Default accent seed (iOS systemBlue). A single color that seeds the tint on
@@ -17,6 +18,13 @@ export const ACCENT_SEED = '#007AFF';
17
18
 
18
19
  const AccentContext = createContext(ACCENT_SEED);
19
20
 
21
+ /**
22
+ * `react-native-keyboard-controller`'s provider when the app has the library
23
+ * (natively only; never on web), so `KeyboardBar` can read the keyboard's
24
+ * movement anywhere under the kit's root. A fragment otherwise.
25
+ */
26
+ const KeyboardProvider: ComponentType<PropsWithChildren> = loadKeyboardController()?.KeyboardProvider ?? Fragment;
27
+
20
28
  /** The active accent seed color (hardcoded default or user-supplied). */
21
29
  export function useAccentSeed(): string {
22
30
  return useContext(AccentContext);
@@ -39,11 +47,12 @@ export function onAccent(seed: string): '#000000' | '#FFFFFF' {
39
47
  }
40
48
 
41
49
  /**
42
- * Provides the accent seed to the app. Pass `seed` to apply a user-supplied
43
- * accent; omit it for the hardcoded default. On web the seed is mirrored to
44
- * the `--color-tint`/`--color-on-tint` custom properties (inline styles win
45
- * over the `:root` defaults emitted by `getThemeCSS`), so all CSS consumers
46
- * react without JS recomputation.
50
+ * Provides the accent seed to the app, and the keyboard provider natively
51
+ * (see `KeyboardBar`). Pass `seed` to apply a user-supplied accent; omit it
52
+ * for the hardcoded default. On web the seed is mirrored to the
53
+ * `--color-tint`/`--color-on-tint` custom properties (inline styles win over
54
+ * the `:root` defaults emitted by `getThemeCSS`), so all CSS consumers react
55
+ * without JS recomputation.
47
56
  */
48
57
  export function AccentProvider({seed = ACCENT_SEED, children}: PropsWithChildren<{seed?: string}>) {
49
58
  useEffect(() => {
@@ -58,5 +67,9 @@ export function AccentProvider({seed = ACCENT_SEED, children}: PropsWithChildren
58
67
  root.style.setProperty('--color-on-tint', onAccent(seed));
59
68
  }, [seed]);
60
69
 
61
- return <AccentContext.Provider value={seed}>{children}</AccentContext.Provider>;
70
+ return (
71
+ <KeyboardProvider>
72
+ <AccentContext.Provider value={seed}>{children}</AccentContext.Provider>
73
+ </KeyboardProvider>
74
+ );
62
75
  }
@@ -101,6 +101,12 @@
101
101
  --ui-button-on-accent: var(--color-on-destructive);
102
102
  }
103
103
 
104
+ /* The `label` tone: a text button drawn as a tool, in the primary text color. */
105
+ .ui-button--label {
106
+ --ui-button-accent: var(--color-label);
107
+ --ui-button-on-accent: var(--color-background);
108
+ }
109
+
104
110
  .ui-button__label {
105
111
  display: inline-block;
106
112
  }
@@ -41,6 +41,7 @@ export function Button({
41
41
  variant = 'filled',
42
42
  role = 'default',
43
43
  color,
44
+ tone = 'accent',
44
45
  size = 'medium',
45
46
  shape,
46
47
  prefixIcon,
@@ -51,9 +52,12 @@ export function Button({
51
52
  testID,
52
53
  }: ButtonProps) {
53
54
  const themeTint = useColor('tint');
55
+ const themeLabel = useColor('label');
54
56
  const destructive = useColor('destructive');
55
57
  const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
56
- const accent = color ?? (role === 'destructive' ? destructive : themeTint);
58
+ // The label tone only applies to the text variant: a tool, not a call to action.
59
+ const labelTone = variant === 'text' && tone === 'label' && role !== 'destructive';
60
+ const accent = color ?? (role === 'destructive' ? destructive : labelTone ? themeLabel : themeTint);
57
61
  // A custom accent brings its own contrast color for filled content.
58
62
  const onAccent = color ? contrastOf(color) : themeOnAccent;
59
63
  const onFilled = variant === 'filled';
@@ -24,6 +24,7 @@ export function Button({
24
24
  variant = 'filled',
25
25
  role = 'default',
26
26
  color,
27
+ tone = 'accent',
27
28
  size = 'medium',
28
29
  shape,
29
30
  prefixIcon,
@@ -34,11 +35,14 @@ export function Button({
34
35
  testID,
35
36
  }: ButtonProps) {
36
37
  const themeTint = useColor('tint');
38
+ const themeLabel = useColor('label');
37
39
  const destructive = useColor('destructive');
38
40
  const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
39
41
  const hasSuffix = !!suffixIcon && !hideLabel;
40
42
  const iconOnly = hideLabel && !!prefixIcon;
41
- const accent = color ?? (role === 'destructive' ? destructive : themeTint);
43
+ // The label tone only applies to the text variant: a tool, not a call to action.
44
+ const labelTone = variant === 'text' && tone === 'label' && role !== 'destructive';
45
+ const accent = color ?? (role === 'destructive' ? destructive : labelTone ? themeLabel : themeTint);
42
46
  // A custom accent brings its own contrast color for filled content.
43
47
  const onAccent = color ? contrastOf(color) : themeOnAccent;
44
48
  const iconColor = variant === 'filled' ? onAccent : accent;
@@ -30,6 +30,7 @@ export function Button({
30
30
  role = 'default',
31
31
  size = 'medium',
32
32
  color,
33
+ tone = 'accent',
33
34
  shape,
34
35
  prefixIcon,
35
36
  suffixIcon,
@@ -41,9 +42,12 @@ export function Button({
41
42
  popoverTargetAction,
42
43
  }: WebButtonProps) {
43
44
  const themeTint = useColor('tint');
45
+ const themeLabel = useColor('label');
44
46
  const destructive = useColor('destructive');
45
47
  const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
46
- const accent = color ?? (role === 'destructive' ? destructive : themeTint);
48
+ // The label tone only applies to the text variant: a tool, not a call to action.
49
+ const labelTone = variant === 'text' && tone === 'label' && role !== 'destructive';
50
+ const accent = color ?? (role === 'destructive' ? destructive : labelTone ? themeLabel : themeTint);
47
51
  // A custom accent brings its own contrast color for filled content.
48
52
  const onAccent = color ? contrastOf(color) : themeOnAccent;
49
53
  const iconOnly = hideLabel && !!prefixIcon;
@@ -59,6 +63,7 @@ export function Button({
59
63
  !shape && 'ui-button--pill',
60
64
  iconOnly && 'ui-button--icon-only',
61
65
  role === 'destructive' && 'ui-button--destructive',
66
+ labelTone && !color && 'ui-button--label',
62
67
  fillWidth && 'ui-button--fill',
63
68
  ].filter(Boolean).join(' ');
64
69
 
@@ -30,6 +30,14 @@ export type ButtonSize = 'small' | 'medium' | 'large';
30
30
  /** Border shape of the button. */
31
31
  export type ButtonShape = 'rounded' | 'pill' | 'circle';
32
32
 
33
+ /**
34
+ * Color the `text` variant draws its label and icons in. `accent` is the
35
+ * theme tint (a call to action); `label` is the primary text color, for
36
+ * tools in a toolbar where the accent is kept for the one that is active.
37
+ * Filled and outlined buttons ignore it. An explicit `color` wins.
38
+ */
39
+ export type ButtonTone = 'accent' | 'label';
40
+
33
41
  export interface ButtonProps {
34
42
  /**
35
43
  * Text shown inside the button. Required for accessibility even when
@@ -50,6 +58,11 @@ export interface ButtonProps {
50
58
  role?: ButtonRole;
51
59
  /** Accent color (tint) for the button. Defaults to the platform/theme tint. */
52
60
  color?: string;
61
+ /**
62
+ * Color of the `text` variant's content.
63
+ * @default 'accent'
64
+ */
65
+ tone?: ButtonTone;
53
66
  /**
54
67
  * Control size of the button.
55
68
  * @default 'medium'
@@ -64,3 +64,73 @@
64
64
  outline-offset: 2px;
65
65
  border-radius: 4px;
66
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
+ }
@@ -2,11 +2,10 @@ import type {ColorPickerProps} from './types';
2
2
  import type {ModifierConfig} from '@expo/ui/jetpack-compose/modifiers';
3
3
 
4
4
  import {useEffect, useRef, useState} from 'react';
5
- import {StyleSheet, useWindowDimensions, View} from 'react-native';
6
- import {Image} from 'expo-image';
5
+ import {useWindowDimensions} from 'react-native';
7
6
  import {
7
+ Box,
8
8
  Column,
9
- Host,
10
9
  ModalBottomSheet,
11
10
  RNHostView,
12
11
  Row,
@@ -15,64 +14,102 @@ import {
15
14
  useMaterialColors,
16
15
  type ModalBottomSheetRef,
17
16
  } from '@expo/ui/jetpack-compose';
18
- import {alpha, clickable, fillMaxWidth, padding, testID as testIDModifier} from '@expo/ui/jetpack-compose/modifiers';
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
19
  import {ColorPickerSheet} from './sheet';
20
- import {parseColor, ringSvg, svgDataUri, toCss, toHex, useColorValue, well} from './shared';
20
+ import {parseColor, toCss, toHex, useColorValue, well} from './shared';
21
21
 
22
- const RING = svgDataUri(ringSvg());
23
22
  /** Horizontal inset of the sheet content (the `@expo/ui` `BottomSheet` default). */
24
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';
25
29
 
26
30
  /**
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`.
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).
34
39
  */
35
40
  export function ColorPicker({
36
41
  label,
37
42
  value,
38
43
  onValueChange,
39
44
  supportsOpacity = true,
45
+ swatches,
40
46
  disabled,
41
47
  testID,
42
48
  }: ColorPickerProps) {
43
49
  const colors = useMaterialColors();
44
50
  const {width} = useWindowDimensions();
51
+ const ring = useColor('separator');
52
+ const labelColor = useColor('label');
45
53
  const [open, setOpen] = useState(false);
46
54
  const [current, setCurrent] = useColorValue(value, onValueChange, supportsOpacity);
47
55
  const modifiers: ModifierConfig[] = [fillMaxWidth()];
48
56
  if (!disabled) modifiers.push(clickable(() => setOpen(true)));
49
57
  if (testID) modifiers.push(testIDModifier(testID));
58
+ const currentHex = toHex({...current, a: 1}, false);
50
59
 
51
60
  return (
52
61
  <Row verticalAlignment="center" horizontalArrangement="spaceBetween" modifiers={modifiers}>
53
62
  {label != null ? (
54
63
  <Text color={disabled ? colors.onSurfaceVariant : colors.onSurface}>{label}</Text>
55
64
  ) : <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>
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>
76
113
  </Row>
77
114
  );
78
115
  }
@@ -80,6 +117,8 @@ export function ColorPicker({
80
117
  /**
81
118
  * The Material bottom sheet hosting the picker, mounted while `open` and
82
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.
83
122
  */
84
123
  function PickerSheet({open, onClose, children}: React.PropsWithChildren<{open: boolean; onClose: () => void}>) {
85
124
  const ref = useRef<ModalBottomSheetRef>(null);
@@ -97,21 +136,10 @@ function PickerSheet({open, onClose, children}: React.PropsWithChildren<{open: b
97
136
  }, [open]);
98
137
  if (!mounted) return null;
99
138
  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>
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>
107
144
  );
108
145
  }
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
- });