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.
- package/README.md +87 -10
- package/package.json +6 -1
- package/src/accent.tsx +21 -8
- package/src/button/button.css +6 -0
- package/src/button/index.android.tsx +5 -1
- package/src/button/index.ios.tsx +5 -1
- package/src/button/index.tsx +6 -1
- package/src/button/types.ts +13 -0
- package/src/color-picker/color-picker.css +70 -0
- package/src/color-picker/index.android.tsx +77 -49
- package/src/color-picker/index.ios.tsx +58 -11
- package/src/color-picker/index.tsx +65 -15
- package/src/color-picker/types.ts +9 -3
- package/src/context-menu/index.android.tsx +39 -11
- package/src/context-menu/index.ios.tsx +3 -1
- package/src/context-menu/index.tsx +24 -7
- package/src/fab/fab.css +72 -0
- package/src/fab/index.android.tsx +72 -0
- package/src/fab/index.ios.tsx +68 -0
- package/src/fab/index.tsx +37 -0
- package/src/fab/shared.ts +23 -0
- package/src/fab/types.ts +39 -0
- package/src/field-group/index.android.tsx +22 -16
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +25 -9
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- package/src/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +31 -5
- package/src/keyboard/index.tsx +81 -0
- package/src/keyboard/library.native.ts +17 -0
- package/src/keyboard/library.ts +12 -0
- package/src/keyboard/types.ts +20 -0
- package/src/list-item/index.android.tsx +23 -6
- package/src/list-item/index.tsx +20 -4
- package/src/list-item/index.web.tsx +60 -0
- package/src/list-item/list-item.css +65 -0
- package/src/list-item/types.ts +21 -0
- package/src/menu/index.android.tsx +22 -9
- package/src/menu/index.ios.tsx +26 -11
- package/src/menu/index.tsx +26 -10
- package/src/menu/list.tsx +25 -10
- package/src/menu/menu.css +52 -0
- package/src/menu/types.ts +41 -2
- package/src/scheme.ts +140 -0
- package/src/screen/index.tsx +49 -5
- package/src/tab-stack/index.tsx +15 -2
- package/src/tabs/index.tsx +2 -1
- package/src/tabs/index.web.tsx +29 -4
- package/src/tabs/types.ts +20 -2
- package/src/text-field/index.android.tsx +30 -6
- package/src/text-field/index.ios.tsx +16 -3
- package/src/text-field/index.tsx +24 -4
- package/src/text-field/inline.tsx +87 -0
- package/src/text-field/shared.ts +40 -1
- package/src/text-field/types.ts +47 -2
- 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
|
-
| [
|
|
99
|
-
| [
|
|
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/
|
|
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
|
|
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.
|
|
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
|
|
43
|
-
*
|
|
44
|
-
* the
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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
|
|
70
|
+
return (
|
|
71
|
+
<KeyboardProvider>
|
|
72
|
+
<AccentContext.Provider value={seed}>{children}</AccentContext.Provider>
|
|
73
|
+
</KeyboardProvider>
|
|
74
|
+
);
|
|
62
75
|
}
|
package/src/button/button.css
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/src/button/index.ios.tsx
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/button/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/button/types.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
28
|
-
*
|
|
29
|
-
* color
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* plain Compose child of its
|
|
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
|
-
<
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
<
|
|
101
|
-
<
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
});
|