expo-interface 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +227 -0
  3. package/package.json +78 -0
  4. package/src/accent.tsx +62 -0
  5. package/src/button/button.css +118 -0
  6. package/src/button/index.android.tsx +114 -0
  7. package/src/button/index.ios.tsx +78 -0
  8. package/src/button/index.tsx +75 -0
  9. package/src/button/shared.ts +42 -0
  10. package/src/button/types.ts +72 -0
  11. package/src/css.d.ts +2 -0
  12. package/src/date-time/index.android.tsx +108 -0
  13. package/src/date-time/index.ios.tsx +54 -0
  14. package/src/date-time/index.tsx +119 -0
  15. package/src/date-time/shared.ts +160 -0
  16. package/src/date-time/types.ts +41 -0
  17. package/src/field-group/field-group.css +18 -0
  18. package/src/field-group/index.android.tsx +193 -0
  19. package/src/field-group/index.tsx +8 -0
  20. package/src/field-group/index.web.tsx +24 -0
  21. package/src/fill/index.android.ts +4 -0
  22. package/src/fill/index.ios.ts +9 -0
  23. package/src/fill/index.ts +10 -0
  24. package/src/global.css +17 -0
  25. package/src/icons.ts +42 -0
  26. package/src/index.ts +59 -0
  27. package/src/link.ts +36 -0
  28. package/src/list-item/index.android.tsx +52 -0
  29. package/src/list-item/index.tsx +21 -0
  30. package/src/list-item/types.ts +21 -0
  31. package/src/picker/index.android.tsx +80 -0
  32. package/src/picker/index.ios.tsx +48 -0
  33. package/src/picker/index.tsx +145 -0
  34. package/src/picker/shared.ts +70 -0
  35. package/src/picker/types.ts +48 -0
  36. package/src/progress/index.android.tsx +24 -0
  37. package/src/progress/index.ios.tsx +20 -0
  38. package/src/progress/index.tsx +30 -0
  39. package/src/progress/progress.css +27 -0
  40. package/src/progress/types.ts +19 -0
  41. package/src/qr/index.tsx +26 -0
  42. package/src/router/external-link.tsx +26 -0
  43. package/src/screen/header.tsx +65 -0
  44. package/src/screen/host-accent.android.ts +8 -0
  45. package/src/screen/host-accent.ios.ts +9 -0
  46. package/src/screen/host-accent.ts +15 -0
  47. package/src/screen/index.tsx +79 -0
  48. package/src/sheet/index.android.tsx +25 -0
  49. package/src/sheet/index.ios.tsx +20 -0
  50. package/src/sheet/index.tsx +18 -0
  51. package/src/stack-header/index.tsx +3 -0
  52. package/src/stack-header/index.web.tsx +33 -0
  53. package/src/switch/index.android.tsx +60 -0
  54. package/src/switch/index.ios.tsx +33 -0
  55. package/src/switch/index.tsx +62 -0
  56. package/src/switch/types.ts +23 -0
  57. package/src/tab-stack/index.tsx +21 -0
  58. package/src/tabs/index.tsx +34 -0
  59. package/src/tabs/index.web.tsx +114 -0
  60. package/src/tabs/types.ts +64 -0
  61. package/src/text-field/index.android.tsx +123 -0
  62. package/src/text-field/index.ios.tsx +73 -0
  63. package/src/text-field/index.tsx +78 -0
  64. package/src/text-field/shared.ts +83 -0
  65. package/src/text-field/types.ts +64 -0
  66. package/src/theme.ts +528 -0
  67. package/src/typography/index.tsx +83 -0
  68. package/src/typography/index.web.tsx +80 -0
  69. package/src/typography/types.ts +45 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kat-tax
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # expo-interface
2
+
3
+ Native-first, universal UI kit for [Expo](https://expo.dev), built on
4
+ [`@expo/ui`](https://docs.expo.dev/versions/v56.0.0/sdk/ui/).
5
+
6
+ Every component renders the platform's real control:
7
+
8
+ | Platform | Renders with |
9
+ | -------- | ------------ |
10
+ | iOS | SwiftUI (`@expo/ui/swift-ui`) |
11
+ | Android | Jetpack Compose / Material 3 (`@expo/ui/jetpack-compose`) |
12
+ | Web | Plain DOM elements styled with CSS custom properties |
13
+
14
+ One accent seed themes all three: it seeds a Material 3 palette on Android,
15
+ cascades as a SwiftUI `tint` on iOS, and is emitted as `--color-*` variables on
16
+ web. The prop surface of each component is the intersection of what the three
17
+ platforms support, so the same JSX renders a native-feeling control everywhere.
18
+
19
+ > **Status:** early. The API follows `@expo/ui`, which is itself still
20
+ > evolving, so expect breaking changes between minor versions.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npx expo install expo-interface @expo/ui expo-router expo-symbols expo-image expo-constants expo-status-bar expo-system-ui expo-web-browser react-native-safe-area-context
26
+ ```
27
+
28
+ `expo-interface` ships TypeScript source (`src/index.ts`) and relies on Metro to
29
+ compile it, exactly like the rest of your app — no build step, and the
30
+ `.ios.tsx` / `.android.tsx` / `.web.tsx` platform files resolve as usual.
31
+
32
+ ### Android drawables (optional)
33
+
34
+ Jetpack Compose controls render icons from drawables rather than symbol glyphs.
35
+ To use Material Symbols as drawables, install `@expo/material-symbols` and
36
+ register `xml` as an asset extension in `metro.config.js`:
37
+
38
+ ```js
39
+ const {getDefaultConfig} = require('expo/metro-config');
40
+
41
+ const config = getDefaultConfig(__dirname);
42
+
43
+ if (!config.resolver.assetExts.includes('xml')) {
44
+ config.resolver.assetExts.push('xml');
45
+ }
46
+
47
+ module.exports = config;
48
+ ```
49
+
50
+ ## Setup
51
+
52
+ ### 1. Provide the accent
53
+
54
+ Wrap the app in `AccentProvider`. Omit `seed` for the default (iOS systemBlue,
55
+ `#007AFF`) or pass a hex color to re-theme every platform at once.
56
+
57
+ ```tsx
58
+ // app/_layout.tsx
59
+ import {ThemeProvider, Stack} from 'expo-router';
60
+ import {AccentProvider, useNavTheme} from 'expo-interface';
61
+
62
+ function Navigation() {
63
+ // Reads the accent seed, so it must render inside AccentProvider.
64
+ return (
65
+ <ThemeProvider value={useNavTheme()}>
66
+ <Stack screenOptions={{headerShown: false}}/>
67
+ </ThemeProvider>
68
+ );
69
+ }
70
+
71
+ export default function Layout() {
72
+ return (
73
+ <AccentProvider seed="#8959EA">
74
+ <Navigation/>
75
+ </AccentProvider>
76
+ );
77
+ }
78
+ ```
79
+
80
+ ### 2. Emit the palette on web
81
+
82
+ `getThemeCSS()` renders the light/dark palette as `--color-*` custom
83
+ properties. Add it to the root HTML so CSS consumers (buttons, typography,
84
+ `theme.*` tokens) resolve without JavaScript.
85
+
86
+ ```tsx
87
+ // app/+html.tsx
88
+ import {ScrollViewStyleReset} from 'expo-router/html';
89
+ import {getThemeCSS} from 'expo-interface';
90
+
91
+ export default function Root({children}: React.PropsWithChildren) {
92
+ return (
93
+ <html lang="en">
94
+ <head>
95
+ <meta charSet="utf-8"/>
96
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
97
+ <style dangerouslySetInnerHTML={{__html: getThemeCSS()}}/>
98
+ <ScrollViewStyleReset/>
99
+ </head>
100
+ <body>{children}</body>
101
+ </html>
102
+ );
103
+ }
104
+ ```
105
+
106
+ ### 3. Build screens
107
+
108
+ `Screen` handles safe areas, the status bar, the system background color, and
109
+ the content max-width. With `native`, children are mounted inside an
110
+ accent-seeded `@expo/ui` `Host`, so you can compose SwiftUI / Compose
111
+ primitives directly.
112
+
113
+ ```tsx
114
+ import {Screen, Button, Title, Body} from 'expo-interface';
115
+
116
+ export default function Home() {
117
+ return (
118
+ <Screen gutter>
119
+ <Title>Hello</Title>
120
+ <Body color="secondaryLabel">Rendered with the platform's own text.</Body>
121
+ <Button label="Continue" onPress={() => {}}/>
122
+ </Screen>
123
+ );
124
+ }
125
+ ```
126
+
127
+ ## Components
128
+
129
+ All exports come from the package root: `import {…} from 'expo-interface'`.
130
+
131
+ ### Layout
132
+
133
+ | Export | Description |
134
+ | ------ | ----------- |
135
+ | `Screen` | Safe-area screen container. Props: `native` (mount an accent-seeded `Host`), `header` (sits under a stack header), `gutter` (horizontal padding). |
136
+ | `ScreenHeader` | Simple header bar with optional back button and trailing slot. |
137
+ | `ConstrainedStackHeader` | `Stack` `header` implementation for web that matches the content max-width (renders nothing on native). |
138
+ | `TabStack` | Pre-configured `expo-router` `Stack` for a tab's root screen, themed from `useNavTheme()`. |
139
+ | `Tabs` | Tab bar: `NativeTabs` on iOS/Android, a floating top bar on web. Props: `routes`, `webLogo` (`'icon-only'`, `'text-only'`, `'icon-and-text'` or a `ReactNode`), `webIcon`. |
140
+ | `Sheet` | Accent-aware `@expo/ui` `BottomSheet`. |
141
+ | `hostAccentProps(seed)` | Extra `Host` props that apply the accent (`seedColor` on Android, `tint` modifier on iOS) — use when mounting your own `Host`. |
142
+ | `fillWidth` | Modifiers that make a universal `Column`/`Row` span its parent's width. |
143
+
144
+ ### Controls
145
+
146
+ | Export | Description |
147
+ | ------ | ----------- |
148
+ | `Button` | `label`, `onPress`, `variant` (`filled` / `outlined` / `text`), `role` (`default` / `destructive`), `size`, `shape` (`rounded` / `pill` / `circle`), `color`, `prefixIcon`, `suffixIcon`, `hideLabel`, `disabled`. |
149
+ | `TextField` | `value`, `onChangeText`, `onSubmit`, `placeholder`, `keyboardType`, `autoCapitalize`, `secureTextEntry`, `multiline`, `maxLength`, `accentColor`, … |
150
+ | `Switch` | `value`, `onValueChange`, `label`, `disabled`, `accentColor`. |
151
+ | `Picker` | `selectedValue`, `onValueChange`, `label`, with `{label, value}` option children. |
152
+ | `DateTimePicker` | `value`, `onChange`, `mode` (`date` / `time` / `datetime`), `minimumDate`, `maximumDate`, `label`. |
153
+ | `Progress` | Linear progress: `value` (0–1, indeterminate when omitted), `color`, `trackColor`. |
154
+ | `ListItem` | Row with `leading`, `trailing`, `supporting` text and `onPress`. |
155
+ | `FieldGroup` | Re-export of `@expo/ui`'s grouped form container (styled on web). |
156
+ | `QRCode` | Renders `value` as a QR image (`size`). |
157
+ | `ExternalLink` | `expo-router` `Link` that opens in an in-app browser on native. |
158
+
159
+ ### Typography
160
+
161
+ `Typography` plus one component per variant: `LargeTitle`, `Title`, `Title2`,
162
+ `Title3`, `Headline`, `Body`, `Callout`, `Subheadline`, `Footnote`, `Caption`,
163
+ `Label`. Props: `color` (a theme token), `weight`, `align`, `numberOfLines`,
164
+ `style`. Metrics follow the iOS text styles on iOS/web and the Material type
165
+ scale on Android.
166
+
167
+ ### Icons
168
+
169
+ Components take an `IconToken` — an `expo-symbols` name (single string or
170
+ `{ios, android, web}` map) plus an optional Android drawable:
171
+
172
+ ```ts
173
+ // icons.drawables.android.ts
174
+ import share from '@expo/material-symbols/share.xml';
175
+ export const drawables = {share};
176
+
177
+ // icons.drawables.ts (iOS/web stub)
178
+ export const drawables: Record<string, ImageSourcePropType | undefined> = {};
179
+
180
+ // icons.ts
181
+ import {icon} from 'expo-interface';
182
+ import {drawables} from './icons.drawables';
183
+
184
+ export const share = icon(
185
+ {ios: 'square.and.arrow.up', android: 'share', web: 'share'},
186
+ drawables.share,
187
+ );
188
+ ```
189
+
190
+ ### Theme
191
+
192
+ | Export | Description |
193
+ | ------ | ----------- |
194
+ | `theme` | Scheme-adaptive color tokens as opaque platform values (`PlatformColor` on iOS, theme attrs on Android, `var(--color-*)` on web) for styles the platform resolves natively. |
195
+ | `useColor(token)` | Hook returning a concrete color string for the active scheme and accent — use for anything that can't consume a `PlatformColor` (symbols, `@expo/ui` props). |
196
+ | `colors` | The raw light/dark palette. |
197
+ | `spacing`, `bound`, `inset`, `fonts`, `fontWeights`, `variants` | Layout and type constants. |
198
+ | `useNavTheme()` | React Navigation theme resolved from the palette and live accent. |
199
+ | `getThemeCSS()` | Palette as `--color-*` CSS for `+html.tsx`. |
200
+ | `AccentProvider`, `useAccentSeed`, `onAccent`, `ACCENT_SEED` | Accent seed context and helpers. |
201
+
202
+ Tokens: `label`, `secondaryLabel`, `tertiaryLabel`, `background`,
203
+ `backgroundElement`, `backgroundSelected`, `separator`, `tint`, `onTint`,
204
+ `pillBackground`, `switchTrack`, `switchOn`, `destructive`, `onDestructive`.
205
+
206
+ ### Utilities
207
+
208
+ `shareUrl(url, message?)` opens the native share sheet / Web Share API;
209
+ `copyText(text)` copies on web and falls back to the share sheet on native.
210
+
211
+ ## Example app
212
+
213
+ [`example/`](./example) is **dropfiles**, a small file-drop app that exercises
214
+ every component in the kit. It resolves the package straight from `../src`
215
+ through the npm workspace, so it doubles as the development harness:
216
+
217
+ ```sh
218
+ npm install
219
+ npm run web # expo start --web
220
+ npm run ios # expo run:ios
221
+ npm run android # expo run:android
222
+ npm run typecheck # package + example
223
+ ```
224
+
225
+ ## License
226
+
227
+ MIT
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "expo-interface",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "author": "kat-tax",
7
+ "homepage": "https://github.com/kat-tax/expo-interface#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kat-tax/expo-interface.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/kat-tax/expo-interface/issues"
14
+ },
15
+ "keywords": [
16
+ "expo",
17
+ "expo-ui",
18
+ "react-native",
19
+ "swiftui",
20
+ "jetpack-compose",
21
+ "ui-kit",
22
+ "design-system",
23
+ "universal"
24
+ ],
25
+ "main": "src/index.ts",
26
+ "types": "src/index.ts",
27
+ "react-native": "src/index.ts",
28
+ "source": "src/index.ts",
29
+ "sideEffects": [
30
+ "**/*.css",
31
+ "src/theme.ts",
32
+ "src/screen/index.tsx"
33
+ ],
34
+ "files": [
35
+ "src",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "workspaces": [
40
+ "example"
41
+ ],
42
+ "scripts": {
43
+ "typecheck": "tsc --noEmit && bun run --cwd example typecheck",
44
+ "lint": "bun run --cwd example lint",
45
+ "start": "bun run --cwd example start",
46
+ "web": "bun run --cwd example web",
47
+ "ios": "bun run --cwd example ios",
48
+ "android": "bun run --cwd example android"
49
+ },
50
+ "dependencies": {
51
+ "qrcode-generator": "^2.0.4"
52
+ },
53
+ "peerDependencies": {
54
+ "@expo/material-symbols": "*",
55
+ "@expo/ui": "^56.0.0",
56
+ "expo": "^56.0.0",
57
+ "expo-constants": "*",
58
+ "expo-image": "*",
59
+ "expo-router": "^56.0.0",
60
+ "expo-status-bar": "*",
61
+ "expo-symbols": "*",
62
+ "expo-system-ui": "*",
63
+ "expo-web-browser": "*",
64
+ "react": "*",
65
+ "react-native": "*",
66
+ "react-native-safe-area-context": "*"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@expo/material-symbols": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@types/react": "~19.2.2",
75
+ "typescript": "~6.0.3"
76
+ },
77
+ "packageManager": "bun@1.3.9"
78
+ }
package/src/accent.tsx ADDED
@@ -0,0 +1,62 @@
1
+ import type {PropsWithChildren} from 'react';
2
+ import {createContext, useContext, useEffect} from 'react';
3
+ import {Platform} from 'react-native';
4
+
5
+ /**
6
+ * Default accent seed (iOS systemBlue). A single color that seeds the tint on
7
+ * every platform:
8
+ * - Android: generates a full Material 3 palette via `SchemeTonalSpot` (the
9
+ * Material You algorithm), light and dark, through the Compose `Host`
10
+ * `seedColor` prop and `useMaterialColors({seedColor})`.
11
+ * - iOS: applied verbatim as the SwiftUI `tint` (cascades from the `Host`),
12
+ * like a single-color AccentColor asset.
13
+ * - Web: emitted as the `--color-tint` default by `getThemeCSS`; runtime
14
+ * overrides are applied as inline custom properties (see AccentProvider).
15
+ */
16
+ export const ACCENT_SEED = '#007AFF';
17
+
18
+ const AccentContext = createContext(ACCENT_SEED);
19
+
20
+ /** The active accent seed color (hardcoded default or user-supplied). */
21
+ export function useAccentSeed(): string {
22
+ return useContext(AccentContext);
23
+ }
24
+
25
+ /**
26
+ * Contrast color (black or white) for content rendered on top of the accent.
27
+ * Used for iOS/web `onTint`; Android uses the seeded palette's `onPrimary`.
28
+ */
29
+ export function onAccent(seed: string): '#000000' | '#FFFFFF' {
30
+ let hex = seed.replace('#', '');
31
+ if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
32
+ const n = parseInt(hex.slice(0, 6), 16);
33
+ if (Number.isNaN(n)) return '#FFFFFF';
34
+ const r = (n >> 16) & 255;
35
+ const g = (n >> 8) & 255;
36
+ const b = n & 255;
37
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
38
+ return luma > 153 ? '#000000' : '#FFFFFF';
39
+ }
40
+
41
+ /**
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.
47
+ */
48
+ export function AccentProvider({seed = ACCENT_SEED, children}: PropsWithChildren<{seed?: string}>) {
49
+ useEffect(() => {
50
+ if (Platform.OS !== 'web' || typeof document === 'undefined') return;
51
+ const root = document.documentElement;
52
+ if (seed === ACCENT_SEED) {
53
+ root.style.removeProperty('--color-tint');
54
+ root.style.removeProperty('--color-on-tint');
55
+ return;
56
+ }
57
+ root.style.setProperty('--color-tint', seed);
58
+ root.style.setProperty('--color-on-tint', onAccent(seed));
59
+ }, [seed]);
60
+
61
+ return <AccentContext.Provider value={seed}>{children}</AccentContext.Provider>;
62
+ }
@@ -0,0 +1,118 @@
1
+ .ui-button {
2
+ --ui-button-accent: var(--color-tint);
3
+ --ui-button-on-accent: var(--color-on-tint);
4
+ -webkit-appearance: none;
5
+ appearance: none;
6
+ display: inline-flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ border-radius: 999px;
10
+ border: 1px solid transparent;
11
+ padding: 10px 18px;
12
+ gap: 8px;
13
+ cursor: pointer;
14
+ font-family: var(--font-display);
15
+ font-size: 15px;
16
+ font-weight: 600;
17
+ line-height: 1.2;
18
+ user-select: none;
19
+ white-space: nowrap;
20
+ transition: background-color 0.15s ease, filter 0.15s ease, opacity 0.15s ease;
21
+ }
22
+
23
+ .ui-button--small {
24
+ font-size: 13px;
25
+ padding: 6px 12px;
26
+ }
27
+
28
+ .ui-button--large {
29
+ font-size: 17px;
30
+ padding: 14px 22px;
31
+ }
32
+
33
+ .ui-button--rounded {
34
+ border-radius: 12px;
35
+ }
36
+
37
+ .ui-button--pill {
38
+ border-radius: 999px;
39
+ }
40
+
41
+ .ui-button--circle {
42
+ border-radius: 50%;
43
+ aspect-ratio: 1;
44
+ padding: 10px;
45
+ }
46
+
47
+ .ui-button--circle.ui-button--small {
48
+ padding: 6px;
49
+ }
50
+
51
+ .ui-button--circle.ui-button--large {
52
+ padding: 14px;
53
+ }
54
+
55
+ .ui-button--icon-only.ui-button--small {
56
+ padding: 6px;
57
+ }
58
+
59
+ .ui-button--icon-only.ui-button--medium {
60
+ padding: 10px;
61
+ }
62
+
63
+ .ui-button--icon-only.ui-button--large {
64
+ padding: 14px;
65
+ }
66
+
67
+ .ui-button--filled {
68
+ color: var(--ui-button-on-accent);
69
+ background: var(--ui-button-accent);
70
+ }
71
+
72
+ .ui-button--outlined {
73
+ color: var(--ui-button-accent);
74
+ background: transparent;
75
+ border-color: var(--ui-button-accent);
76
+ }
77
+
78
+ .ui-button--text {
79
+ color: var(--ui-button-accent);
80
+ background: transparent;
81
+ border-color: transparent;
82
+ padding-inline: 10px;
83
+ }
84
+
85
+ .ui-button--text.ui-button--small {
86
+ padding-inline: 6px;
87
+ }
88
+
89
+ .ui-button--text.ui-button--large {
90
+ padding-inline: 14px;
91
+ }
92
+
93
+ .ui-button--destructive {
94
+ --ui-button-accent: var(--color-destructive);
95
+ --ui-button-on-accent: var(--color-on-destructive);
96
+ }
97
+
98
+ .ui-button__label {
99
+ display: inline-block;
100
+ }
101
+
102
+ .ui-button:hover:not(:disabled) {
103
+ filter: brightness(1.08);
104
+ }
105
+
106
+ .ui-button--outlined:hover:not(:disabled),
107
+ .ui-button--text:hover:not(:disabled) {
108
+ background: color-mix(in srgb, var(--ui-button-accent) 12%, transparent);
109
+ }
110
+
111
+ .ui-button:active:not(:disabled) {
112
+ filter: brightness(0.92);
113
+ }
114
+
115
+ .ui-button:disabled {
116
+ opacity: 0.45;
117
+ cursor: default;
118
+ }
@@ -0,0 +1,114 @@
1
+ import type {ButtonProps, ButtonShape, ButtonVariant} from './types';
2
+ import {testID as testIDModifier, width} from '@expo/ui/jetpack-compose/modifiers';
3
+ import {Button as ComposeButton, OutlinedButton, TextButton, Icon, IconButton, FilledIconButton, OutlinedIconButton, Spacer, Shape, Text} from '@expo/ui/jetpack-compose';
4
+ import {SIZE_ICON, SIZE_TEXT, androidContentPadding} from './shared';
5
+ import {onAccent as contrastOf} from '../accent';
6
+ import {useColor} from '../theme';
7
+
8
+ const VARIANT_COMPONENT: Record<ButtonVariant, typeof ComposeButton | typeof OutlinedButton | typeof TextButton> = {
9
+ filled: ComposeButton,
10
+ outlined: OutlinedButton,
11
+ text: TextButton,
12
+ };
13
+
14
+ const ICON_ONLY_COMPONENT: Record<ButtonVariant, typeof FilledIconButton | typeof OutlinedIconButton | typeof IconButton> = {
15
+ filled: FilledIconButton,
16
+ outlined: OutlinedIconButton,
17
+ text: IconButton,
18
+ };
19
+
20
+ function resolveShape(shape?: ButtonShape) {
21
+ if (!shape) return undefined;
22
+ switch (shape) {
23
+ case 'rounded':
24
+ return Shape.RoundedCorner({cornerRadii: {topStart: 12, topEnd: 12, bottomStart: 12, bottomEnd: 12}});
25
+ case 'pill':
26
+ return Shape.Pill({});
27
+ case 'circle':
28
+ return Shape.Circle({});
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Android renders the matching Material 3 button component for each variant.
34
+ * The accent defaults to the theme tint (red for the destructive role) and is
35
+ * applied as the container color (filled) or content color (outlined/text), so
36
+ * the button is branded instead of falling back to the device's Material theme.
37
+ */
38
+ export function Button({
39
+ label,
40
+ onPress,
41
+ variant = 'filled',
42
+ role = 'default',
43
+ color,
44
+ size = 'medium',
45
+ shape,
46
+ prefixIcon,
47
+ suffixIcon,
48
+ hideLabel = false,
49
+ disabled,
50
+ testID,
51
+ }: ButtonProps) {
52
+ const themeTint = useColor('tint');
53
+ const destructive = useColor('destructive');
54
+ const themeOnAccent = useColor(role === 'destructive' ? 'onDestructive' : 'onTint');
55
+ const accent = color ?? (role === 'destructive' ? destructive : themeTint);
56
+ // A custom accent brings its own contrast color for filled content.
57
+ const onAccent = color ? contrastOf(color) : themeOnAccent;
58
+ const onFilled = variant === 'filled';
59
+ const textColor = onFilled ? onAccent : accent;
60
+ const colors = onFilled
61
+ ? {containerColor: accent, contentColor: onAccent}
62
+ : {contentColor: accent};
63
+ const iconSize = SIZE_ICON[size];
64
+ const textSize = SIZE_TEXT[size];
65
+ const resolvedShape = resolveShape(shape);
66
+ const modifiers = testID ? [testIDModifier(testID)] : [];
67
+ const iconOnly = hideLabel && !!prefixIcon?.drawable;
68
+
69
+ if (iconOnly) {
70
+ const IconComponent = ICON_ONLY_COMPONENT[variant];
71
+ return (
72
+ <IconComponent
73
+ onClick={disabled ? undefined : onPress}
74
+ enabled={!disabled}
75
+ colors={colors}
76
+ shape={resolvedShape}
77
+ modifiers={modifiers}>
78
+ <Icon
79
+ source={prefixIcon!.drawable!}
80
+ size={iconSize}
81
+ tint={textColor}
82
+ contentDescription={label}
83
+ />
84
+ </IconComponent>
85
+ );
86
+ }
87
+
88
+ const Component = VARIANT_COMPONENT[variant];
89
+ const pad = androidContentPadding(size, !!(prefixIcon?.drawable || suffixIcon?.drawable));
90
+
91
+ return (
92
+ <Component
93
+ onClick={disabled ? undefined : onPress}
94
+ enabled={!disabled}
95
+ colors={colors}
96
+ shape={resolvedShape}
97
+ contentPadding={pad}
98
+ modifiers={modifiers}>
99
+ {prefixIcon?.drawable ? (
100
+ <>
101
+ <Icon source={prefixIcon.drawable} size={iconSize} tint={textColor}/>
102
+ <Spacer modifiers={[width(8)]}/>
103
+ </>
104
+ ) : null}
105
+ <Text color={textColor} style={{fontSize: textSize}}>{label}</Text>
106
+ {suffixIcon?.drawable ? (
107
+ <>
108
+ <Spacer modifiers={[width(8)]}/>
109
+ <Icon source={suffixIcon.drawable} size={iconSize} tint={textColor}/>
110
+ </>
111
+ ) : null}
112
+ </Component>
113
+ );
114
+ }