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.
- package/LICENSE +21 -0
- package/README.md +227 -0
- package/package.json +78 -0
- package/src/accent.tsx +62 -0
- package/src/button/button.css +118 -0
- package/src/button/index.android.tsx +114 -0
- package/src/button/index.ios.tsx +78 -0
- package/src/button/index.tsx +75 -0
- package/src/button/shared.ts +42 -0
- package/src/button/types.ts +72 -0
- package/src/css.d.ts +2 -0
- package/src/date-time/index.android.tsx +108 -0
- package/src/date-time/index.ios.tsx +54 -0
- package/src/date-time/index.tsx +119 -0
- package/src/date-time/shared.ts +160 -0
- package/src/date-time/types.ts +41 -0
- package/src/field-group/field-group.css +18 -0
- package/src/field-group/index.android.tsx +193 -0
- package/src/field-group/index.tsx +8 -0
- package/src/field-group/index.web.tsx +24 -0
- package/src/fill/index.android.ts +4 -0
- package/src/fill/index.ios.ts +9 -0
- package/src/fill/index.ts +10 -0
- package/src/global.css +17 -0
- package/src/icons.ts +42 -0
- package/src/index.ts +59 -0
- package/src/link.ts +36 -0
- package/src/list-item/index.android.tsx +52 -0
- package/src/list-item/index.tsx +21 -0
- package/src/list-item/types.ts +21 -0
- package/src/picker/index.android.tsx +80 -0
- package/src/picker/index.ios.tsx +48 -0
- package/src/picker/index.tsx +145 -0
- package/src/picker/shared.ts +70 -0
- package/src/picker/types.ts +48 -0
- package/src/progress/index.android.tsx +24 -0
- package/src/progress/index.ios.tsx +20 -0
- package/src/progress/index.tsx +30 -0
- package/src/progress/progress.css +27 -0
- package/src/progress/types.ts +19 -0
- package/src/qr/index.tsx +26 -0
- package/src/router/external-link.tsx +26 -0
- package/src/screen/header.tsx +65 -0
- package/src/screen/host-accent.android.ts +8 -0
- package/src/screen/host-accent.ios.ts +9 -0
- package/src/screen/host-accent.ts +15 -0
- package/src/screen/index.tsx +79 -0
- package/src/sheet/index.android.tsx +25 -0
- package/src/sheet/index.ios.tsx +20 -0
- package/src/sheet/index.tsx +18 -0
- package/src/stack-header/index.tsx +3 -0
- package/src/stack-header/index.web.tsx +33 -0
- package/src/switch/index.android.tsx +60 -0
- package/src/switch/index.ios.tsx +33 -0
- package/src/switch/index.tsx +62 -0
- package/src/switch/types.ts +23 -0
- package/src/tab-stack/index.tsx +21 -0
- package/src/tabs/index.tsx +34 -0
- package/src/tabs/index.web.tsx +114 -0
- package/src/tabs/types.ts +64 -0
- package/src/text-field/index.android.tsx +123 -0
- package/src/text-field/index.ios.tsx +73 -0
- package/src/text-field/index.tsx +78 -0
- package/src/text-field/shared.ts +83 -0
- package/src/text-field/types.ts +64 -0
- package/src/theme.ts +528 -0
- package/src/typography/index.tsx +83 -0
- package/src/typography/index.web.tsx +80 -0
- 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
|
+
}
|