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.
Files changed (78) hide show
  1. package/README.md +119 -20
  2. package/package.json +9 -9
  3. package/src/accent.tsx +21 -8
  4. package/src/alert/alert.css +3 -3
  5. package/src/alert/index.tsx +1 -2
  6. package/src/button/button.css +12 -0
  7. package/src/button/index.android.tsx +14 -3
  8. package/src/button/index.ios.tsx +13 -4
  9. package/src/button/index.tsx +8 -1
  10. package/src/button/types.ts +20 -0
  11. package/src/checkbox/index.tsx +1 -1
  12. package/src/color-picker/color-picker.css +136 -0
  13. package/src/color-picker/index.android.tsx +145 -0
  14. package/src/color-picker/index.ios.tsx +80 -0
  15. package/src/color-picker/index.tsx +112 -0
  16. package/src/color-picker/shared.ts +204 -0
  17. package/src/color-picker/sheet.tsx +432 -0
  18. package/src/color-picker/types.ts +40 -0
  19. package/src/context-menu/index.android.tsx +39 -11
  20. package/src/context-menu/index.ios.tsx +3 -1
  21. package/src/context-menu/index.tsx +24 -7
  22. package/src/fab/fab.css +72 -0
  23. package/src/fab/index.android.tsx +72 -0
  24. package/src/fab/index.ios.tsx +68 -0
  25. package/src/fab/index.tsx +37 -0
  26. package/src/fab/shared.ts +23 -0
  27. package/src/fab/types.ts +39 -0
  28. package/src/field-group/field-group.css +23 -7
  29. package/src/field-group/index.android.tsx +44 -38
  30. package/src/field-group/index.tsx +36 -5
  31. package/src/field-group/index.web.tsx +27 -11
  32. package/src/field-group/shared.tsx +49 -0
  33. package/src/field-group/types.ts +25 -0
  34. package/src/gauge/gauge.css +200 -0
  35. package/src/gauge/index.android.tsx +212 -0
  36. package/src/gauge/index.ios.tsx +40 -0
  37. package/src/gauge/index.tsx +165 -0
  38. package/src/gauge/shared.ts +79 -0
  39. package/src/gauge/types.ts +64 -0
  40. package/src/header-menu/index.tsx +76 -0
  41. package/src/host/index.tsx +32 -0
  42. package/src/index.ts +35 -6
  43. package/src/keyboard/index.tsx +81 -0
  44. package/src/keyboard/library.native.ts +17 -0
  45. package/src/keyboard/library.ts +12 -0
  46. package/src/keyboard/types.ts +20 -0
  47. package/src/list-item/index.android.tsx +23 -6
  48. package/src/list-item/index.tsx +20 -4
  49. package/src/list-item/index.web.tsx +60 -0
  50. package/src/list-item/list-item.css +65 -0
  51. package/src/list-item/types.ts +21 -0
  52. package/src/menu/index.android.tsx +22 -9
  53. package/src/menu/index.ios.tsx +26 -11
  54. package/src/menu/index.tsx +26 -10
  55. package/src/menu/list.tsx +25 -10
  56. package/src/menu/menu.css +52 -0
  57. package/src/menu/types.ts +41 -2
  58. package/src/progress/progress.css +9 -0
  59. package/src/scheme.ts +140 -0
  60. package/src/screen/header.tsx +1 -0
  61. package/src/screen/index.tsx +49 -5
  62. package/src/segmented/index.tsx +1 -1
  63. package/src/segmented/segmented.css +6 -7
  64. package/src/slider/index.tsx +1 -1
  65. package/src/stepper/index.tsx +1 -1
  66. package/src/switch/index.tsx +2 -0
  67. package/src/tab-stack/index.tsx +15 -2
  68. package/src/tabs/index.tsx +2 -1
  69. package/src/tabs/index.web.tsx +29 -4
  70. package/src/tabs/types.ts +20 -2
  71. package/src/text-field/index.android.tsx +30 -6
  72. package/src/text-field/index.ios.tsx +16 -3
  73. package/src/text-field/index.tsx +24 -4
  74. package/src/text-field/inline.tsx +87 -0
  75. package/src/text-field/shared.ts +40 -1
  76. package/src/text-field/types.ts +47 -2
  77. package/src/theme.ts +44 -5
  78. 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
- | [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 | ✓ | ✓ | ✓ |
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
- | [Menu](src/menu/types.ts) | Dropdown menu of actions opened from a button | ✓ | ✓ | ✓ |
117
- | [ContextMenu](src/menu/types.ts) | Menu of actions opened by long-pressing (or right-clicking) its content | ✓ | ✓ | ✓ |
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 # eslint (eslint-config-expo)
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 run
253
- in the [storybook](./storybook) app, an Expo project built on
254
- `@storybook/react-native`, so every story renders the real SwiftUI, Compose
255
- or DOM control through Metro.
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 in the browser
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 export in storybook/dist
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 global decorator (`storybook/.rnstorybook/preview.tsx`) wraps stories in
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.1.1",
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": "eslint . --max-warnings 0",
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. 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
  }
@@ -23,9 +23,9 @@
23
23
  .ui-alert__body {
24
24
  display: flex;
25
25
  flex-direction: column;
26
- align-items: center;
27
- gap: 4px;
28
- text-align: center;
26
+ align-items: flex-start;
27
+ gap: 16px;
28
+ text-align: left;
29
29
  }
30
30
 
31
31
  .ui-alert__actions {
@@ -17,8 +17,7 @@ export function Alert({title, message, visible, onDismiss, actions = DEFAULT_ACT
17
17
  const {cancel, others} = splitActions(actions);
18
18
 
19
19
  useEffect(() => {
20
- const dialog = ref.current;
21
- if (!dialog) return;
20
+ const dialog = ref.current!;
22
21
  if (visible && !dialog.open) dialog.showModal();
23
22
  else if (!visible && dialog.open) dialog.close();
24
23
  }, [visible]);
@@ -6,6 +6,8 @@
6
6
  display: inline-flex;
7
7
  align-items: center;
8
8
  justify-content: center;
9
+ /* Hug the content even inside a flex column that stretches its children. */
10
+ width: fit-content;
9
11
  border-radius: 999px;
10
12
  border: 1px solid transparent;
11
13
  padding: 10px 18px;
@@ -90,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
- 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);
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
- const modifiers = testID ? [testIDModifier(testID)] : [];
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) {
@@ -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
- 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);
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
- if (hasSuffix) {
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
  );
@@ -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
- 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);
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 (
@@ -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
  }
@@ -22,7 +22,7 @@ export function Checkbox({
22
22
  }: CheckboxProps) {
23
23
  const vars = {
24
24
  ...(accentColor ? {'--ui-checkbox-accent': accentColor} : null),
25
- ...flatten((StyleSheet.flatten(style) ?? undefined) as TextStyle | undefined),
25
+ ...flatten(StyleSheet.flatten(style) as TextStyle),
26
26
  } as CSSProperties;
27
27
  const input = (
28
28
  <input