expo-interface 0.1.1 → 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 +119 -20
- package/package.json +9 -9
- package/src/accent.tsx +21 -8
- package/src/alert/alert.css +3 -3
- package/src/alert/index.tsx +1 -2
- package/src/button/button.css +12 -0
- package/src/button/index.android.tsx +14 -3
- package/src/button/index.ios.tsx +13 -4
- package/src/button/index.tsx +8 -1
- package/src/button/types.ts +20 -0
- package/src/checkbox/index.tsx +1 -1
- package/src/color-picker/color-picker.css +136 -0
- package/src/color-picker/index.android.tsx +145 -0
- package/src/color-picker/index.ios.tsx +80 -0
- package/src/color-picker/index.tsx +112 -0
- package/src/color-picker/shared.ts +204 -0
- package/src/color-picker/sheet.tsx +432 -0
- package/src/color-picker/types.ts +40 -0
- 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/field-group.css +23 -7
- package/src/field-group/index.android.tsx +44 -38
- package/src/field-group/index.tsx +36 -5
- package/src/field-group/index.web.tsx +27 -11
- package/src/field-group/shared.tsx +49 -0
- package/src/field-group/types.ts +25 -0
- 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/header-menu/index.tsx +76 -0
- package/src/host/index.tsx +32 -0
- package/src/index.ts +35 -6
- 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/progress/progress.css +9 -0
- package/src/scheme.ts +140 -0
- package/src/screen/header.tsx +1 -0
- package/src/screen/index.tsx +49 -5
- 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/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/src/qr/index.tsx +0 -26
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,31 +96,36 @@ 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 | ✓ | ✓ | ✓ |
|
|
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 | ✓ | ✓ | ✓ |
|
|
110
118
|
| [Slider](src/slider/types.ts) | Thumb dragged along a continuous or stepped range | ✓ | ✓ | ✓ |
|
|
111
119
|
| [Stepper](src/stepper/types.ts) | Number adjusted with increment and decrement buttons | ✓ | ✓ | ✓ |
|
|
112
120
|
| [Picker](src/picker/types.ts) | Dropdown that selects one option from a list | ✓ | ✓ | ✓ |
|
|
113
121
|
| [SegmentedControl](src/segmented/types.ts) | Row of segments that selects one option | ✓ | ✓ | ✓ |
|
|
114
122
|
| [DateTimePicker](src/date-time/types.ts) | Picks a date, a time or both, with optional bounds | ✓ | ✓ | ✓ |
|
|
115
123
|
| [Progress](src/progress/types.ts) | Linear bar or circular ring, determinate or indeterminate | ✓ | ✓ | ✓ |
|
|
116
|
-
| [
|
|
117
|
-
| [
|
|
124
|
+
| [Gauge](src/gauge/types.ts) | Value within a range in the SwiftUI gauge styles: capacity bars, marker bar, open or closed ring | ✓ | ✓ | ✓ |
|
|
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 | ✓ | ✓ | ✓ |
|
|
118
127
|
| [Tooltip](src/tooltip/types.ts) | Short hint shown on hover, focus or long-press; an accessibility hint on iOS | | ✓ | ✓ |
|
|
119
128
|
| [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
129
|
| [ExternalLink](src/router/external-link.tsx) | Link that opens in an in-app browser on native and a new tab on web | ✓ | ✓ | ✓ |
|
|
122
130
|
| [Typography](src/typography/types.ts) | Text in the platform type scale, with `Title`, `Body`, `Caption` and other variants as shortcuts | ✓ | ✓ | ✓ |
|
|
123
131
|
|
|
@@ -201,16 +209,86 @@ import {theme} from 'expo-interface';
|
|
|
201
209
|
| `destructive` | Delete buttons, failed states and other destructive actions |
|
|
202
210
|
| `onDestructive` | Text and icons drawn on top of `destructive` |
|
|
203
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
|
+
|
|
204
247
|
#### Other exports
|
|
205
248
|
|
|
206
249
|
| Export | Purpose |
|
|
207
250
|
| --- | --- |
|
|
208
251
|
| `useNavTheme()` | React Navigation theme built from the palette and accent |
|
|
209
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 |
|
|
210
256
|
| `colors` | Raw light and dark palettes |
|
|
211
257
|
| `spacing`, `bound`, `inset` | Layout constants |
|
|
212
258
|
| `fonts`, `fontWeights`, `variants` | Type constants |
|
|
213
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
|
+
|
|
214
292
|
## Install details
|
|
215
293
|
|
|
216
294
|
The components are built on standard Expo modules, which are peer
|
|
@@ -242,27 +320,37 @@ package from `../src`, so it doubles as the development harness.
|
|
|
242
320
|
bun install # bun >= 1.4
|
|
243
321
|
bun run web # or ios, android
|
|
244
322
|
bun run typecheck # package, example and storybook
|
|
245
|
-
bun run lint #
|
|
323
|
+
bun run lint # oxlint
|
|
246
324
|
bun run test # vitest, once per platform (ios, android, web)
|
|
247
325
|
bun run test:ui # vitest watch mode with the browser UI
|
|
248
326
|
```
|
|
249
327
|
|
|
250
328
|
### Storybook
|
|
251
329
|
|
|
252
|
-
Stories live next to each component (`src/<name>/<name>.stories.tsx`) and
|
|
253
|
-
in the [storybook](./storybook)
|
|
254
|
-
|
|
255
|
-
|
|
330
|
+
Stories live next to each component (`src/<name>/<name>.stories.tsx`) and are
|
|
331
|
+
shared by two Storybooks in the [storybook](./storybook) workspace:
|
|
332
|
+
|
|
333
|
+
- **Web** (`storybook/.storybook`): `@storybook/react-native-web-vite`, so the
|
|
334
|
+
site has the full Storybook manager — MDX guides in `storybook/docs/`, an
|
|
335
|
+
autogenerated docs page with a props table per component, controls, a
|
|
336
|
+
black/white theme that follows the OS scheme and a toolbar to force light or
|
|
337
|
+
dark. This is the documentation site published to GitHub Pages.
|
|
338
|
+
- **On-device** (`storybook/.rnstorybook`): `@storybook/react-native` through
|
|
339
|
+
Metro, rendering the real SwiftUI and Compose controls on iOS and Android.
|
|
256
340
|
|
|
257
341
|
```sh
|
|
258
|
-
bun run storybook:web # storybook
|
|
342
|
+
bun run storybook:web # web storybook + docs site (Vite)
|
|
259
343
|
bun run storybook:ios # or storybook:android — on-device storybook
|
|
260
|
-
bun run storybook:build # static web
|
|
344
|
+
bun run storybook:build # static web build in storybook/dist
|
|
345
|
+
bun run storybook:test # every story as a Vitest test in headless Chromium
|
|
261
346
|
```
|
|
262
347
|
|
|
263
|
-
The
|
|
348
|
+
The shared decorator (`storybook/src/frame.tsx`) wraps stories in
|
|
264
349
|
`AccentProvider` and an accent-seeded `@expo/ui` `Host`. Stories built from
|
|
265
|
-
plain React Native views opt out with `parameters: {native: false}
|
|
350
|
+
plain React Native views opt out with `parameters: {native: false}`; a story
|
|
351
|
+
can pick an accent with `parameters: {accent: '#8959EA'}`. The web build runs
|
|
352
|
+
the kit through Vite with the same Metro-compat plugin as the Vitest web
|
|
353
|
+
project (`vitest/metro-compat.ts`).
|
|
266
354
|
|
|
267
355
|
### Tests
|
|
268
356
|
|
|
@@ -285,6 +373,16 @@ props are the payload sent to SwiftUI/Compose, and `src/__tests__/native.ts`
|
|
|
285
373
|
has helpers to assert on them. `bun run test:coverage` writes an interactive
|
|
286
374
|
HTML test report to `test-report/` and coverage to `coverage/`.
|
|
287
375
|
|
|
376
|
+
The stories double as tests: `@storybook/addon-vitest`
|
|
377
|
+
(`storybook/vitest.config.mts`) renders every web story in headless Chromium
|
|
378
|
+
through Vitest browser mode, so a story that throws while mounting fails the
|
|
379
|
+
run, and `@storybook/addon-a11y` runs axe-core on each rendered story as part
|
|
380
|
+
of it (`parameters.a11y.test: 'error'`; the `color-contrast` rule is off
|
|
381
|
+
because the palette follows the iOS system colors). `bun run storybook:test` runs them from the CLI (Playwright's Chromium
|
|
382
|
+
must be installed: `bunx playwright install chromium` in `storybook/`); in
|
|
383
|
+
`storybook:web` the testing widget at the bottom of the sidebar runs them
|
|
384
|
+
live.
|
|
385
|
+
|
|
288
386
|
### CI
|
|
289
387
|
|
|
290
388
|
GitHub Actions ([.github/workflows](./.github/workflows)):
|
|
@@ -293,7 +391,8 @@ GitHub Actions ([.github/workflows](./.github/workflows)):
|
|
|
293
391
|
(HTML report and coverage as artifacts), a Metro export of the example app
|
|
294
392
|
for ios, android and web (proves every platform file and
|
|
295
393
|
`@expo/material-symbols` asset resolves without Xcode or Gradle), and a web
|
|
296
|
-
Storybook build uploaded as an artifact
|
|
394
|
+
Storybook build uploaded as an artifact after its stories pass as Vitest
|
|
395
|
+
browser tests.
|
|
297
396
|
- **Storybook** (`storybook.yml`) — on push to `master`: publishes the web
|
|
298
397
|
Storybook to GitHub Pages, with the Vitest HTML report at `/tests` and
|
|
299
398
|
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.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": [
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
],
|
|
49
50
|
"scripts": {
|
|
50
51
|
"typecheck": "tsc --noEmit && bun run --cwd example typecheck && bun run --cwd storybook typecheck",
|
|
51
|
-
"lint": "
|
|
52
|
+
"lint": "oxlint --max-warnings 0",
|
|
52
53
|
"test": "vitest run",
|
|
53
54
|
"test:watch": "vitest",
|
|
54
55
|
"test:coverage": "vitest run --coverage",
|
|
@@ -61,11 +62,9 @@
|
|
|
61
62
|
"storybook:ios": "bun run --cwd storybook ios",
|
|
62
63
|
"storybook:android": "bun run --cwd storybook android",
|
|
63
64
|
"storybook:build": "bun run --cwd storybook build",
|
|
65
|
+
"storybook:test": "bun run --cwd storybook test",
|
|
64
66
|
"test:ui": "vitest --ui"
|
|
65
67
|
},
|
|
66
|
-
"dependencies": {
|
|
67
|
-
"qrcode-generator": "^2.0.4"
|
|
68
|
-
},
|
|
69
68
|
"peerDependencies": {
|
|
70
69
|
"@expo/material-symbols": "*",
|
|
71
70
|
"@expo/ui": "^57.0.0",
|
|
@@ -79,11 +78,15 @@
|
|
|
79
78
|
"expo-web-browser": "*",
|
|
80
79
|
"react": "*",
|
|
81
80
|
"react-native": "*",
|
|
81
|
+
"react-native-keyboard-controller": "*",
|
|
82
82
|
"react-native-safe-area-context": "*"
|
|
83
83
|
},
|
|
84
84
|
"peerDependenciesMeta": {
|
|
85
85
|
"@expo/material-symbols": {
|
|
86
86
|
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"react-native-keyboard-controller": {
|
|
89
|
+
"optional": true
|
|
87
90
|
}
|
|
88
91
|
},
|
|
89
92
|
"devDependencies": {
|
|
@@ -91,13 +94,10 @@
|
|
|
91
94
|
"@testing-library/react": "^16.3.3",
|
|
92
95
|
"@testing-library/react-native": "^14.0.1",
|
|
93
96
|
"@types/react": "~19.2.2",
|
|
94
|
-
"@typescript-eslint/parser": "8.59.0",
|
|
95
97
|
"@vitest/coverage-v8": "^4.1.11",
|
|
96
98
|
"@vitest/ui": "^4.1.11",
|
|
97
|
-
"eslint": "^9.39.5",
|
|
98
|
-
"eslint-config-expo": "~57.0.2",
|
|
99
|
-
"globals": "^16.0.0",
|
|
100
99
|
"jsdom": "^30.0.1",
|
|
100
|
+
"oxlint": "^1.81.0",
|
|
101
101
|
"test-renderer": "^1.2.0",
|
|
102
102
|
"typescript": "~6.0.3",
|
|
103
103
|
"vite": "^8.2.2",
|
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/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,11 +92,21 @@
|
|
|
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);
|
|
96
102
|
}
|
|
97
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
|
+
|
|
98
110
|
.ui-button__label {
|
|
99
111
|
display: inline-block;
|
|
100
112
|
}
|
|
@@ -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';
|
|
@@ -41,18 +41,23 @@ 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,
|
|
47
48
|
suffixIcon,
|
|
48
49
|
hideLabel = false,
|
|
49
50
|
disabled,
|
|
51
|
+
fillWidth = false,
|
|
50
52
|
testID,
|
|
51
53
|
}: ButtonProps) {
|
|
52
54
|
const themeTint = useColor('tint');
|
|
55
|
+
const themeLabel = useColor('label');
|
|
53
56
|
const destructive = useColor('destructive');
|
|
54
57
|
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
55
|
-
|
|
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);
|
|
56
61
|
// A custom accent brings its own contrast color for filled content.
|
|
57
62
|
const onAccent = color ? contrastOf(color) : themeOnAccent;
|
|
58
63
|
const onFilled = variant === 'filled';
|
|
@@ -63,7 +68,13 @@ export function Button({
|
|
|
63
68
|
const iconSize = SIZE_ICON[size];
|
|
64
69
|
const textSize = SIZE_TEXT[size];
|
|
65
70
|
const resolvedShape = resolveShape(shape);
|
|
66
|
-
|
|
71
|
+
// A `Host` measures a direct child with its own (tight) constraints, which
|
|
72
|
+
// a Compose button would fill on both axes; wrap to the content instead.
|
|
73
|
+
const modifiers = [
|
|
74
|
+
fillWidth ? fillMaxWidth() : wrapContentWidth('start'),
|
|
75
|
+
wrapContentHeight('top'),
|
|
76
|
+
];
|
|
77
|
+
if (testID) modifiers.push(testIDModifier(testID));
|
|
67
78
|
const iconOnly = hideLabel && !!prefixIcon?.drawable;
|
|
68
79
|
|
|
69
80
|
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'> = {
|
|
@@ -23,20 +24,25 @@ export function Button({
|
|
|
23
24
|
variant = 'filled',
|
|
24
25
|
role = 'default',
|
|
25
26
|
color,
|
|
27
|
+
tone = 'accent',
|
|
26
28
|
size = 'medium',
|
|
27
29
|
shape,
|
|
28
30
|
prefixIcon,
|
|
29
31
|
suffixIcon,
|
|
30
32
|
hideLabel = false,
|
|
31
33
|
disabled,
|
|
34
|
+
fillWidth = false,
|
|
32
35
|
testID,
|
|
33
36
|
}: ButtonProps) {
|
|
34
37
|
const themeTint = useColor('tint');
|
|
38
|
+
const themeLabel = useColor('label');
|
|
35
39
|
const destructive = useColor('destructive');
|
|
36
40
|
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
37
41
|
const hasSuffix = !!suffixIcon && !hideLabel;
|
|
38
42
|
const iconOnly = hideLabel && !!prefixIcon;
|
|
39
|
-
|
|
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);
|
|
40
46
|
// A custom accent brings its own contrast color for filled content.
|
|
41
47
|
const onAccent = color ? contrastOf(color) : themeOnAccent;
|
|
42
48
|
const iconColor = variant === 'filled' ? onAccent : accent;
|
|
@@ -49,17 +55,20 @@ export function Button({
|
|
|
49
55
|
if (shape) modifiers.push(buttonBorderShape(swiftBorderShape(shape)));
|
|
50
56
|
if (disabled) modifiers.push(disabledMod(true));
|
|
51
57
|
if (iconOnly) modifiers.push(labelStyle('iconOnly'));
|
|
52
|
-
|
|
58
|
+
// SwiftUI has no trailing-icon `Label`, and a bordered style only paints
|
|
59
|
+
// behind the label, so both cases compose the label by hand: the frame
|
|
60
|
+
// that fills the width goes on the label, not the button.
|
|
61
|
+
if ((hasSuffix || fillWidth) && !iconOnly) {
|
|
53
62
|
return (
|
|
54
63
|
<SwiftUIButton
|
|
55
64
|
role={role === 'destructive' ? 'destructive' : 'default'}
|
|
56
65
|
onPress={onPress}
|
|
57
66
|
modifiers={modifiers}
|
|
58
67
|
testID={testID}>
|
|
59
|
-
<HStack spacing={ICON_GAP}>
|
|
68
|
+
<HStack spacing={ICON_GAP} modifiers={fillWidth ? fillWidthModifiers : undefined}>
|
|
60
69
|
{prefixIcon ? <Image systemName={iosSymbol(prefixIcon)} color={iconColor} size={SIZE_ICON[size]}/> : null}
|
|
61
70
|
<Text>{label}</Text>
|
|
62
|
-
<Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/>
|
|
71
|
+
{hasSuffix ? <Image systemName={iosSymbol(suffixIcon!)} color={iconColor} size={SIZE_ICON[size]}/> : null}
|
|
63
72
|
</HStack>
|
|
64
73
|
</SwiftUIButton>
|
|
65
74
|
);
|
package/src/button/index.tsx
CHANGED
|
@@ -30,19 +30,24 @@ 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,
|
|
36
37
|
hideLabel = false,
|
|
37
38
|
disabled = false,
|
|
39
|
+
fillWidth = false,
|
|
38
40
|
testID,
|
|
39
41
|
popoverTarget,
|
|
40
42
|
popoverTargetAction,
|
|
41
43
|
}: WebButtonProps) {
|
|
42
44
|
const themeTint = useColor('tint');
|
|
45
|
+
const themeLabel = useColor('label');
|
|
43
46
|
const destructive = useColor('destructive');
|
|
44
47
|
const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
|
|
45
|
-
|
|
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);
|
|
46
51
|
// A custom accent brings its own contrast color for filled content.
|
|
47
52
|
const onAccent = color ? contrastOf(color) : themeOnAccent;
|
|
48
53
|
const iconOnly = hideLabel && !!prefixIcon;
|
|
@@ -58,6 +63,8 @@ export function Button({
|
|
|
58
63
|
!shape && 'ui-button--pill',
|
|
59
64
|
iconOnly && 'ui-button--icon-only',
|
|
60
65
|
role === 'destructive' && 'ui-button--destructive',
|
|
66
|
+
labelTone && !color && 'ui-button--label',
|
|
67
|
+
fillWidth && 'ui-button--fill',
|
|
61
68
|
].filter(Boolean).join(' ');
|
|
62
69
|
|
|
63
70
|
return (
|
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'
|
|
@@ -67,6 +80,13 @@ export interface ButtonProps {
|
|
|
67
80
|
hideLabel?: boolean;
|
|
68
81
|
/** Disables interaction and dims the button. */
|
|
69
82
|
disabled?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Stretch the button to its container's full width. By default the button
|
|
85
|
+
* hugs its content, even when the parent would stretch it (a flex column on
|
|
86
|
+
* web, a `Host` on Android).
|
|
87
|
+
* @default false
|
|
88
|
+
*/
|
|
89
|
+
fillWidth?: boolean;
|
|
70
90
|
/** Identifier used to locate the component in end-to-end tests. */
|
|
71
91
|
testID?: string;
|
|
72
92
|
}
|
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
|