@mrmeg/expo-ui 0.21.0 → 0.23.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/CHANGELOG.md +83 -0
- package/LLM_USAGE.md +8 -1
- package/README.md +18 -0
- package/dist/components/Accordion.js +1 -1
- package/dist/components/BottomSheet.js +1 -1
- package/dist/components/Card.js +7 -7
- package/dist/components/Checkbox.js +1 -1
- package/dist/components/Dialog.js +24 -5
- package/dist/components/DismissKeyboard.js +1 -1
- package/dist/components/EmptyState.js +2 -2
- package/dist/components/Item.d.ts +3 -2
- package/dist/components/Item.js +8 -6
- package/dist/components/KeyboardAvoidingView.d.ts +4 -2
- package/dist/components/KeyboardAvoidingView.js +5 -3
- package/dist/components/Notification.js +7 -7
- package/dist/components/RadioGroup.js +1 -1
- package/dist/components/StatCard.js +1 -1
- package/dist/components/keyboardController.d.ts +46 -0
- package/dist/components/keyboardController.js +89 -0
- package/dist/components/keyboardController.native.d.ts +9 -0
- package/dist/components/keyboardController.native.js +9 -0
- package/dist/constants/colors.d.ts +34 -0
- package/dist/constants/colors.js +93 -3
- package/dist/constants/spacing.d.ts +16 -6
- package/dist/constants/spacing.js +17 -6
- package/dist/hooks/useDimensions.d.ts +7 -5
- package/dist/hooks/useDimensions.js +27 -8
- package/dist/hooks/useResources.d.ts +17 -0
- package/dist/hooks/useResources.js +29 -0
- package/dist/hooks/useTheme.d.ts +18 -0
- package/dist/hooks/useTheme.js +43 -13
- package/dist/state/SsrViewportContext.d.ts +12 -0
- package/dist/state/SsrViewportContext.js +13 -0
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,89 @@
|
|
|
3
3
|
All notable changes to `@mrmeg/expo-ui` are documented here. This project
|
|
4
4
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [0.23.0]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Density tokens.** `spacing` gains `smd` (12) and `mdl` (20) scale steps plus
|
|
11
|
+
semantic layout tokens: `screenPadding`, `sectionSpacing` (now 24),
|
|
12
|
+
`cardPadding`, `dialogPadding`, `rowPaddingY`/`rowPaddingX`, `rowGap`,
|
|
13
|
+
`rowMinHeight`, and `formRowMinHeight`. Components read these so a single
|
|
14
|
+
retune shifts the whole library's density.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Compact surfaces and rows.** `Card` header/content/footer padding drops
|
|
19
|
+
from 24 to 16 with a 16px title. `Item` rows use 10/16 padding and a 12px
|
|
20
|
+
gap, with a 40px visual min height on web while native keeps the 44pt touch
|
|
21
|
+
target. `Checkbox` and `RadioGroup` rows are 32px on web (native unchanged).
|
|
22
|
+
`Dialog`/`AlertDialog` padding is 20, `StatCard` padding is 16,
|
|
23
|
+
`EmptyState` vertical padding is 32, and `Accordion` triggers use 12px
|
|
24
|
+
vertical padding.
|
|
25
|
+
- **Dialog spacing is symmetric.** `DialogContent` and `AlertDialogContent`
|
|
26
|
+
space their children with a 16px gap instead of fixed header/footer
|
|
27
|
+
margins, so a header-only dialog no longer reads bottom-heavy.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Dialog could not be dismissed on web.** The web overlay primitive wrapped
|
|
32
|
+
the backdrop in a react-native-web `Pressable` whose click handler stopped
|
|
33
|
+
propagation, so outside presses (mouse and touch) and Escape never reached
|
|
34
|
+
Radix. `DialogContent` now renders the overlay `asChild` on web; native is
|
|
35
|
+
unchanged.
|
|
36
|
+
|
|
37
|
+
## [0.22.0]
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
|
|
41
|
+
- **CSS-variable theming on web.** On web, every semantic theme color
|
|
42
|
+
(`theme.colors.*`) now resolves to a CSS custom property
|
|
43
|
+
(`var(--c-<kebab-token>)`) instead of a literal hex string, so styles built
|
|
44
|
+
from the theme — including the HTML shells baked at export time — re-theme
|
|
45
|
+
purely in CSS when `html[data-theme]` changes. Native platforms keep
|
|
46
|
+
literal values. New exports from `constants`:
|
|
47
|
+
- `getThemeCssVariables(overrides?)` — the `--c-*` (and companion
|
|
48
|
+
`--c-*-rgb` triplet) definitions per scheme, for embedding in an app's
|
|
49
|
+
`+html.tsx` global `<style>`. Accepts per-scheme brand-color overrides.
|
|
50
|
+
- `rawThemeColors.light/.dark` — the literal hex/rgba maps on every
|
|
51
|
+
platform, for sinks that cannot take `var()` (e.g.
|
|
52
|
+
`<meta name="theme-color">`).
|
|
53
|
+
- `resolveRawColor(color, scheme)` — maps a `var(--c-*)` theme color back
|
|
54
|
+
to the given scheme's literal value; literal inputs pass through.
|
|
55
|
+
- **`withAlpha` is exported standalone** (in addition to the `useTheme`
|
|
56
|
+
return) so module-scope style factories can import it. It understands
|
|
57
|
+
`var(--c-*)` inputs and rewrites them onto the `--c-*-rgb` triplet
|
|
58
|
+
(`var(--c-unset, rgba(var(--c-x-rgb), a))` — the never-defined outer
|
|
59
|
+
variable makes react-native-web's compiler pass the value through), staying
|
|
60
|
+
pure CSS that re-themes with the variable. Hex-suffix alpha concatenation
|
|
61
|
+
(`theme.colors.x + "15"`) does not work with `var()` colors — use
|
|
62
|
+
`withAlpha(theme.colors.x, 0.08)`.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- The contrast helpers from `useTheme` (`getContrastingColor`,
|
|
67
|
+
`getContrastRatio`, `getTextColorForBackground`) resolve `var(--c-*)`
|
|
68
|
+
inputs to the active scheme's literal value before parsing, and
|
|
69
|
+
`getContrastingColor` returns the winning candidate as originally passed
|
|
70
|
+
(a `var()` input stays a `var()` output).
|
|
71
|
+
- The React Navigation theme maps (`colors.<scheme>.navigation`) stay
|
|
72
|
+
literal on web as well as native: @react-navigation internals parse their
|
|
73
|
+
theme colors with a color library, which cannot take `var()` references.
|
|
74
|
+
|
|
75
|
+
## [0.21.1]
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **Exported web HTML renders icon glyphs, fixing React hydration error
|
|
80
|
+
#418 on every cold load.** `useResources` registers the Feather icon font
|
|
81
|
+
per render scope during `expo export`'s Node rendering
|
|
82
|
+
(`ensureIconFontRegistered`), so the static shell emits the glyph and its
|
|
83
|
+
font styles. Previously the shell contained an empty icon text node while
|
|
84
|
+
the client's first render (font pre-loaded at module scope) contained the
|
|
85
|
+
glyph — React 19 threw #418 and regenerated the tree on every cold load.
|
|
86
|
+
The same mechanism existed during the request-time SSR era and was removed
|
|
87
|
+
with it; static export needs it too.
|
|
88
|
+
|
|
6
89
|
## [0.21.0]
|
|
7
90
|
|
|
8
91
|
### Added
|
package/LLM_USAGE.md
CHANGED
|
@@ -157,11 +157,18 @@ semantic colors without destructuring `withAlpha` outside the factory:
|
|
|
157
157
|
const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
158
158
|
card: {
|
|
159
159
|
backgroundColor: withAlpha(theme.colors.primary, 0.08),
|
|
160
|
-
padding: spacing.
|
|
160
|
+
padding: spacing.cardPadding,
|
|
161
161
|
},
|
|
162
162
|
}));
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
Layout spacing uses semantic density tokens rather than raw scale steps:
|
|
166
|
+
`spacing.screenPadding` (16) for screen and block gutters, `spacing.cardPadding`
|
|
167
|
+
(16) for bordered panels, `spacing.sectionSpacing` (24) between grouped lists,
|
|
168
|
+
`spacing.dialogPadding` (20) for dialogs, and `spacing.rowPaddingY`/`rowPaddingX`
|
|
169
|
+
(10/16) with `spacing.rowGap` (12) for list rows. `Item` already applies the row
|
|
170
|
+
tokens and keeps a 44px hit area on native while rendering 40px on web.
|
|
171
|
+
|
|
165
172
|
When the saved theme preference is `system`, the package theme store owns the
|
|
166
173
|
OS color-scheme subscription, including web `prefers-color-scheme`. Do not add
|
|
167
174
|
app-local Appearance or `matchMedia` listeners for package components; import
|
package/README.md
CHANGED
|
@@ -124,6 +124,24 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
|
124
124
|
}));
|
|
125
125
|
```
|
|
126
126
|
|
|
127
|
+
### Spacing and density
|
|
128
|
+
|
|
129
|
+
`spacing` exposes an 8px base scale (`xxs` 2, `xs` 4, `sm` 8, `smd` 12, `md` 16, `mdl` 20, `lg` 24, `xl` 32, `xxl` 48, `xxxl` 64) plus semantic density tokens that package components read from. Reach for the semantic names in app code so a future density tune lands everywhere at once:
|
|
130
|
+
|
|
131
|
+
| Token | Value | Use |
|
|
132
|
+
|-------|-------|-----|
|
|
133
|
+
| `screenPadding` | 16 | Horizontal gutter for screens, scroll content, and landing blocks |
|
|
134
|
+
| `sectionSpacing` | 24 | Gap between grouped lists, cards, or settings groups |
|
|
135
|
+
| `cardPadding` | 16 | `Card`, `StatCard`, and any bordered panel |
|
|
136
|
+
| `dialogPadding` | 20 | `Dialog` and `AlertDialog` content |
|
|
137
|
+
| `rowPaddingY` / `rowPaddingX` | 10 / 16 | `Item` rows and hand-rolled list rows |
|
|
138
|
+
| `rowGap` | 12 | Gap between row media, content, and actions |
|
|
139
|
+
| `rowMinHeight` | 40 | Visual row height on web; native rows keep `touchTarget` |
|
|
140
|
+
| `formRowMinHeight` | 32 | Checkbox and radio rows on web; native keeps `touchTarget` |
|
|
141
|
+
| `touchTarget` | 44 | Minimum native hit area; pair a smaller visual height with `hitSlop` |
|
|
142
|
+
|
|
143
|
+
Controls (`Button`, `TextInput`, `Select`, `Tabs`, `Toggle`) size themselves from their own `size` prop (28/32/36/40) and do not read these tokens.
|
|
144
|
+
|
|
127
145
|
### Color overrides
|
|
128
146
|
|
|
129
147
|
The package ships a neutral default palette. `useTheme()` resolves colors in
|
|
@@ -132,7 +132,7 @@ function AccordionTrigger({ children, style: styleOverride, disabled, ...props }
|
|
|
132
132
|
justifyContent: "space-between",
|
|
133
133
|
gap: spacing.md,
|
|
134
134
|
borderRadius: spacing.radiusMd,
|
|
135
|
-
paddingVertical: spacing.
|
|
135
|
+
paddingVertical: spacing.smd,
|
|
136
136
|
...(Platform.OS === "web" && {
|
|
137
137
|
cursor: "pointer",
|
|
138
138
|
userSelect: "none",
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import React, { createContext, use, useCallback, useEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
|
|
3
3
|
import { View, Pressable, StyleSheet, ScrollView, Platform, Animated, useWindowDimensions, } from "react-native";
|
|
4
4
|
import { BottomSheet as NativeBottomSheet } from "@expo/ui/community/bottom-sheet";
|
|
5
|
-
import { KeyboardController } from "
|
|
5
|
+
import { KeyboardController } from "./keyboardController";
|
|
6
6
|
import { useSafeAreaInsets, initialWindowMetrics } from "react-native-safe-area-context";
|
|
7
7
|
import { useTheme } from "../hooks/useTheme.js";
|
|
8
8
|
import { spacing } from "../constants/spacing.js";
|
package/dist/components/Card.js
CHANGED
|
@@ -98,24 +98,24 @@ const createCardStyles = (theme) => StyleSheet.create({
|
|
|
98
98
|
backgroundColor: "transparent",
|
|
99
99
|
},
|
|
100
100
|
header: {
|
|
101
|
-
padding: spacing.
|
|
101
|
+
padding: spacing.cardPadding,
|
|
102
102
|
paddingBottom: spacing.xs,
|
|
103
103
|
gap: spacing.xs,
|
|
104
104
|
},
|
|
105
105
|
content: {
|
|
106
|
-
paddingHorizontal: spacing.
|
|
107
|
-
paddingBottom: spacing.
|
|
106
|
+
paddingHorizontal: spacing.cardPadding,
|
|
107
|
+
paddingBottom: spacing.cardPadding,
|
|
108
108
|
},
|
|
109
109
|
footer: {
|
|
110
110
|
flexDirection: "row",
|
|
111
111
|
alignItems: "center",
|
|
112
|
-
paddingHorizontal: spacing.
|
|
113
|
-
paddingBottom: spacing.
|
|
112
|
+
paddingHorizontal: spacing.cardPadding,
|
|
113
|
+
paddingBottom: spacing.cardPadding,
|
|
114
114
|
paddingTop: 0,
|
|
115
115
|
},
|
|
116
116
|
title: {
|
|
117
|
-
fontSize:
|
|
118
|
-
lineHeight:
|
|
117
|
+
fontSize: 16,
|
|
118
|
+
lineHeight: 22,
|
|
119
119
|
letterSpacing: 0,
|
|
120
120
|
},
|
|
121
121
|
description: {
|
|
@@ -117,7 +117,7 @@ const styles = StyleSheet.create({
|
|
|
117
117
|
flexDirection: "row",
|
|
118
118
|
alignItems: "center",
|
|
119
119
|
gap: spacing.sm,
|
|
120
|
-
minHeight: spacing.touchTarget,
|
|
120
|
+
minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
|
|
121
121
|
},
|
|
122
122
|
labelContainer: {
|
|
123
123
|
flex: 1,
|
|
@@ -22,7 +22,25 @@ function DialogRoot({ children, ...props }) {
|
|
|
22
22
|
function DialogContent({ portalHost, style, children, ...props }) {
|
|
23
23
|
const { theme, getShadowStyle, getContrastingColor } = useTheme();
|
|
24
24
|
const textColor = getContrastingColor(theme.colors.popover, palette.white, palette.black);
|
|
25
|
-
return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
|
|
25
|
+
return (_jsx(DialogPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(DialogPrimitive.Overlay
|
|
26
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
27
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
28
|
+
// With that Pressable between the overlay and the Radix content,
|
|
29
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
30
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
31
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
32
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
33
|
+
// which is what implements closeOnPress there.
|
|
34
|
+
, {
|
|
35
|
+
// On web the primitive wraps the overlay in a react-native-web
|
|
36
|
+
// Pressable whose click handler stops propagation unconditionally.
|
|
37
|
+
// With that Pressable between the overlay and the Radix content,
|
|
38
|
+
// outside presses (mouse and touch) and Escape never dismissed the
|
|
39
|
+
// dialog, so a dialog without a Close button was stuck. `asChild`
|
|
40
|
+
// makes the primitive clone our fade wrapper instead, removing the
|
|
41
|
+
// Pressable; Radix dismissal then works. Native keeps the Pressable,
|
|
42
|
+
// which is what implements closeOnPress there.
|
|
43
|
+
asChild: Platform.OS === "web", style: StyleSheet.flatten([
|
|
26
44
|
StyleSheet.absoluteFill,
|
|
27
45
|
{ backgroundColor: theme.colors.overlay },
|
|
28
46
|
Platform.OS === "web" && { zIndex: 50 },
|
|
@@ -32,7 +50,8 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
32
50
|
borderColor: theme.colors.border,
|
|
33
51
|
borderWidth: 1,
|
|
34
52
|
borderRadius: spacing.radiusLg,
|
|
35
|
-
padding: spacing.
|
|
53
|
+
padding: spacing.dialogPadding,
|
|
54
|
+
gap: spacing.md,
|
|
36
55
|
width: "100%",
|
|
37
56
|
...getShadowStyle("soft"),
|
|
38
57
|
},
|
|
@@ -40,7 +59,7 @@ function DialogContent({ portalHost, style, children, ...props }) {
|
|
|
40
59
|
]), ...props, children: children }) }) }) }) }) }) }) }) }));
|
|
41
60
|
}
|
|
42
61
|
function DialogHeader({ children, style, ...props }) {
|
|
43
|
-
return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs
|
|
62
|
+
return (_jsx(View, { style: StyleSheet.flatten([{ gap: spacing.xs }, style]), ...props, children: children }));
|
|
44
63
|
}
|
|
45
64
|
function DialogFooter({ children, style, ...props }) {
|
|
46
65
|
return (_jsx(View, { style: StyleSheet.flatten([
|
|
@@ -48,7 +67,6 @@ function DialogFooter({ children, style, ...props }) {
|
|
|
48
67
|
flexDirection: "row",
|
|
49
68
|
justifyContent: "flex-end",
|
|
50
69
|
gap: spacing.sm,
|
|
51
|
-
marginTop: spacing.lg,
|
|
52
70
|
},
|
|
53
71
|
style,
|
|
54
72
|
]), ...props, children: children }));
|
|
@@ -107,7 +125,8 @@ function AlertDialogContent({ portalHost, style, children, ...props }) {
|
|
|
107
125
|
borderColor: theme.colors.border,
|
|
108
126
|
borderWidth: 1,
|
|
109
127
|
borderRadius: spacing.radiusLg,
|
|
110
|
-
padding: spacing.
|
|
128
|
+
padding: spacing.dialogPadding,
|
|
129
|
+
gap: spacing.md,
|
|
111
130
|
width: "100%",
|
|
112
131
|
...getShadowStyle("soft"),
|
|
113
132
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React from "react";
|
|
3
3
|
import { Platform, ScrollView, View, } from "react-native";
|
|
4
|
-
import { KeyboardController, useKeyboardContext, useKeyboardState, } from "
|
|
4
|
+
import { KeyboardController, useKeyboardContext, useKeyboardState, } from "./keyboardController";
|
|
5
5
|
import { KeyboardAvoidingView, useKeyboardAvoidance } from "./KeyboardAvoidingView.js";
|
|
6
6
|
import { isTouchInsideKeyboardFocusedInput } from "./keyboardFocusRegistry.js";
|
|
7
7
|
/**
|
|
@@ -33,7 +33,7 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
33
33
|
container: {
|
|
34
34
|
alignItems: "center",
|
|
35
35
|
justifyContent: "center",
|
|
36
|
-
paddingVertical: spacing.
|
|
36
|
+
paddingVertical: spacing.xl,
|
|
37
37
|
paddingHorizontal: spacing.lg,
|
|
38
38
|
},
|
|
39
39
|
bordered: {
|
|
@@ -41,7 +41,7 @@ const createStyles = (theme) => StyleSheet.create({
|
|
|
41
41
|
borderStyle: "dashed",
|
|
42
42
|
borderColor: theme.colors.border,
|
|
43
43
|
borderRadius: spacing.radiusLg,
|
|
44
|
-
padding: spacing.
|
|
44
|
+
padding: spacing.lg,
|
|
45
45
|
},
|
|
46
46
|
iconWrapper: {
|
|
47
47
|
width: MEDIA_SIZE,
|
|
@@ -16,8 +16,9 @@ export interface ItemProps {
|
|
|
16
16
|
/**
|
|
17
17
|
* Item
|
|
18
18
|
*
|
|
19
|
-
* Grouped-list row container: `spacing.
|
|
20
|
-
* 44pt min height
|
|
19
|
+
* Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
|
|
20
|
+
* `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
|
|
21
|
+
* and an optional pressable scale interaction. Compose
|
|
21
22
|
* with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
|
|
22
23
|
* `ItemActions`.
|
|
23
24
|
*
|
package/dist/components/Item.js
CHANGED
|
@@ -8,12 +8,13 @@ import { spacing } from "../constants/spacing.js";
|
|
|
8
8
|
// Default ItemMedia footprint (see ItemMedia's `size` prop) — used to inset
|
|
9
9
|
// the optional separator past the media slot without needing a context.
|
|
10
10
|
const DEFAULT_MEDIA_SIZE = 40;
|
|
11
|
-
const SEPARATOR_INSET = spacing.
|
|
11
|
+
const SEPARATOR_INSET = spacing.rowPaddingX + DEFAULT_MEDIA_SIZE + spacing.rowGap;
|
|
12
12
|
/**
|
|
13
13
|
* Item
|
|
14
14
|
*
|
|
15
|
-
* Grouped-list row container: `spacing.
|
|
16
|
-
* 44pt min height
|
|
15
|
+
* Grouped-list row container: compact row padding (`spacing.rowPaddingY` /
|
|
16
|
+
* `spacing.rowPaddingX`) and gap, a 44pt min height on native (40 on web),
|
|
17
|
+
* and an optional pressable scale interaction. Compose
|
|
17
18
|
* with `ItemMedia`, `ItemContent` (+ `ItemTitle`/`ItemDescription`), and
|
|
18
19
|
* `ItemActions`.
|
|
19
20
|
*
|
|
@@ -104,9 +105,10 @@ const styles = StyleSheet.create({
|
|
|
104
105
|
row: {
|
|
105
106
|
flexDirection: "row",
|
|
106
107
|
alignItems: "center",
|
|
107
|
-
minHeight: spacing.touchTarget,
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
minHeight: Platform.select({ web: spacing.rowMinHeight, default: spacing.touchTarget }),
|
|
109
|
+
paddingVertical: spacing.rowPaddingY,
|
|
110
|
+
paddingHorizontal: spacing.rowPaddingX,
|
|
111
|
+
gap: spacing.rowGap,
|
|
110
112
|
},
|
|
111
113
|
content: {
|
|
112
114
|
flex: 1,
|
|
@@ -13,8 +13,10 @@ export declare function useKeyboardAvoidance(): boolean;
|
|
|
13
13
|
/**
|
|
14
14
|
* Package-level keyboard avoiding wrapper.
|
|
15
15
|
*
|
|
16
|
-
* Native uses `react-native-keyboard-controller`
|
|
17
|
-
*
|
|
16
|
+
* Native uses `react-native-keyboard-controller` (through the platform-split
|
|
17
|
+
* `./keyboardController` module, which keeps the package out of the web bundle)
|
|
18
|
+
* so screens can avoid the soft keyboard with `automaticOffset`; web renders a
|
|
19
|
+
* plain `View`.
|
|
18
20
|
*/
|
|
19
21
|
export declare function KeyboardAvoidingView({ children, style, behavior, automaticOffset, contentContainerStyle, keyboardVerticalOffset, ...props }: KeyboardAvoidingViewProps): React.JSX.Element;
|
|
20
22
|
export {};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
3
|
import { Platform, View, } from "react-native";
|
|
4
|
-
import {
|
|
4
|
+
import { NativeKeyboardAvoidingView } from "./keyboardController";
|
|
5
5
|
const KeyboardAvoidanceContext = createContext(false);
|
|
6
6
|
export function useKeyboardAvoidance() {
|
|
7
7
|
return useContext(KeyboardAvoidanceContext);
|
|
@@ -9,8 +9,10 @@ export function useKeyboardAvoidance() {
|
|
|
9
9
|
/**
|
|
10
10
|
* Package-level keyboard avoiding wrapper.
|
|
11
11
|
*
|
|
12
|
-
* Native uses `react-native-keyboard-controller`
|
|
13
|
-
*
|
|
12
|
+
* Native uses `react-native-keyboard-controller` (through the platform-split
|
|
13
|
+
* `./keyboardController` module, which keeps the package out of the web bundle)
|
|
14
|
+
* so screens can avoid the soft keyboard with `automaticOffset`; web renders a
|
|
15
|
+
* plain `View`.
|
|
14
16
|
*/
|
|
15
17
|
export function KeyboardAvoidingView({ children, style, behavior = Platform.OS === "ios" ? "padding" : "height", automaticOffset = true, contentContainerStyle, keyboardVerticalOffset, ...props }) {
|
|
16
18
|
if (Platform.OS === "web") {
|
|
@@ -3,7 +3,7 @@ import { useCallback, use, useEffect, useEffectEvent, useRef } from "react";
|
|
|
3
3
|
import { Animated, Easing, StyleSheet, View, ActivityIndicator, Pressable, Platform } from "react-native";
|
|
4
4
|
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
|
5
5
|
import { Icon } from "./Icon.js";
|
|
6
|
-
import { useTheme } from "../hooks/useTheme.js";
|
|
6
|
+
import { useTheme, withAlpha } from "../hooks/useTheme.js";
|
|
7
7
|
import { useReducedMotion } from "../hooks/useReduceMotion.js";
|
|
8
8
|
import { spacing } from "../constants/spacing.js";
|
|
9
9
|
import { durations } from "../constants/motion.js";
|
|
@@ -158,26 +158,26 @@ export const Notification = () => {
|
|
|
158
158
|
return {
|
|
159
159
|
icon: "alert-circle",
|
|
160
160
|
color: theme.colors.destructive,
|
|
161
|
-
bgColor: theme.colors.destructive
|
|
161
|
+
bgColor: withAlpha(theme.colors.destructive, 0.08),
|
|
162
162
|
};
|
|
163
163
|
case "success":
|
|
164
164
|
return {
|
|
165
165
|
icon: "check-circle",
|
|
166
166
|
color: theme.colors.success,
|
|
167
|
-
bgColor: theme.colors.success
|
|
167
|
+
bgColor: withAlpha(theme.colors.success, 0.08),
|
|
168
168
|
};
|
|
169
169
|
case "warning":
|
|
170
170
|
return {
|
|
171
171
|
icon: "alert-triangle",
|
|
172
172
|
color: theme.colors.warning,
|
|
173
|
-
bgColor: theme.colors.warning
|
|
173
|
+
bgColor: withAlpha(theme.colors.warning, 0.08),
|
|
174
174
|
};
|
|
175
175
|
case "info":
|
|
176
176
|
default:
|
|
177
177
|
return {
|
|
178
178
|
icon: "info",
|
|
179
179
|
color: theme.colors.accent,
|
|
180
|
-
bgColor: theme.colors.accent
|
|
180
|
+
bgColor: withAlpha(theme.colors.accent, 0.08),
|
|
181
181
|
};
|
|
182
182
|
}
|
|
183
183
|
};
|
|
@@ -223,8 +223,8 @@ export const Notification = () => {
|
|
|
223
223
|
], children: [_jsx(View, { style: [styles.iconBadge, { backgroundColor: iconBgColor }], children: alert?.loading ? (_jsx(ActivityIndicator, { size: "small", color: iconColor })) : (_jsx(Icon, { name: icon, size: 18, color: iconColor })) }), _jsxs(View, { style: styles.alertContent, children: [!!title && (_jsx(StyledText, { selectable: false, size: "base", fontWeight: "semibold", style: { color: theme.colors.foreground }, numberOfLines: 1, children: title })), hasMessage && (_jsx(StyledText, { selectable: false, size: "sm", style: { color: theme.colors.mutedForeground }, numberOfLines: 2, children: message }))] }), action && (_jsx(Pressable, { style: ({ pressed }) => [
|
|
224
224
|
styles.actionButton,
|
|
225
225
|
{
|
|
226
|
-
borderColor: theme.colors.primary
|
|
227
|
-
backgroundColor: theme.colors.primary
|
|
226
|
+
borderColor: withAlpha(theme.colors.primary, 0.19),
|
|
227
|
+
backgroundColor: withAlpha(theme.colors.primary, 0.06),
|
|
228
228
|
},
|
|
229
229
|
pressed && styles.actionButtonPressed,
|
|
230
230
|
], hitSlop: spacing.xs, onPress: handleActionPress, accessibilityLabel: action.label, accessibilityRole: "button", children: _jsx(StyledText, { selectable: false, size: "sm", fontWeight: "semibold", style: { color: theme.colors.primary }, numberOfLines: 1, children: action.label }) })), _jsx(Pressable, { style: styles.closeButton, hitSlop: spacing.sm, onPress: animateOut, accessibilityLabel: "Dismiss notification", accessibilityRole: "button", children: _jsx(Icon, { name: "x", size: 16, color: theme.colors.mutedForeground }) })] }) }));
|
|
@@ -151,7 +151,7 @@ const styles = StyleSheet.create({
|
|
|
151
151
|
flexDirection: "row",
|
|
152
152
|
alignItems: "center",
|
|
153
153
|
gap: spacing.sm,
|
|
154
|
-
minHeight: spacing.touchTarget,
|
|
154
|
+
minHeight: Platform.select({ web: spacing.formRowMinHeight, default: spacing.touchTarget }),
|
|
155
155
|
},
|
|
156
156
|
labelContainer: {
|
|
157
157
|
flex: 1,
|
|
@@ -37,5 +37,5 @@ export function StatCard({ label, value, unit, change, icon, onPress, style }) {
|
|
|
37
37
|
: change.direction === "down"
|
|
38
38
|
? theme.colors.destructive
|
|
39
39
|
: theme.colors.textDim;
|
|
40
|
-
return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.
|
|
40
|
+
return (_jsx(Card, { onPress: onPress, style: style, children: _jsxs(View, { style: { padding: spacing.cardPadding, gap: spacing.xs }, children: [_jsxs(View, { style: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, children: [_jsx(CaptionText, { selectable: false, style: { color: theme.colors.textDim, textTransform: "uppercase", letterSpacing: 0.96 }, children: label }), !!icon && _jsx(Icon, { name: icon, size: spacing.iconSm, color: theme.colors.mutedForeground, decorative: true })] }), _jsxs(View, { style: { flexDirection: "row", alignItems: "flex-end" }, children: [_jsx(StyledText, { selectable: false, size: "xxl", fontWeight: "bold", style: { color: theme.colors.foreground, fontVariant: ["tabular-nums"] }, children: value }), !!unit && (_jsx(StyledText, { selectable: false, size: "base", style: { color: theme.colors.textDim, marginLeft: spacing.xxs, marginBottom: 2 }, children: unit }))] }), !!change && (_jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: spacing.xxs }, children: [change.direction !== "neutral" && (_jsx(Icon, { name: DIRECTION_ICON[change.direction], size: spacing.iconXs, color: changeColor, decorative: true })), _jsx(StyledText, { selectable: false, size: "sm", style: { color: changeColor }, children: change.value })] }))] }) }));
|
|
41
41
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-controller indirection — web variant (inert stubs).
|
|
3
|
+
*
|
|
4
|
+
* `react-native-keyboard-controller` drags a large native animation runtime
|
|
5
|
+
* into every bundle that imports it, and none of that code can run on web.
|
|
6
|
+
* Components therefore import the keyboard API from this module instead of the
|
|
7
|
+
* package: Metro resolves `keyboardController.native.ts` (the real re-exports)
|
|
8
|
+
* on iOS/Android and this file on web, so the package never reaches the web
|
|
9
|
+
* bundle.
|
|
10
|
+
*
|
|
11
|
+
* Both variants are typed against the package's own types — type-only imports
|
|
12
|
+
* are erased at compile time and bundle nothing — so the two files stay
|
|
13
|
+
* interchangeable to the type checker. TypeScript always resolves this `.ts`
|
|
14
|
+
* variant for consumers, which is why the stubs must keep the full native
|
|
15
|
+
* signatures.
|
|
16
|
+
*
|
|
17
|
+
* Every stub here is a plain function that calls no hooks, so the `use*`
|
|
18
|
+
* exports are safe from any call site (rules of hooks can't be violated by a
|
|
19
|
+
* function that has no hook calls of its own).
|
|
20
|
+
*/
|
|
21
|
+
import React from "react";
|
|
22
|
+
import { View } from "react-native";
|
|
23
|
+
import type { IKeyboardState, KeyboardAnimationContext, KeyboardAvoidingViewProps, KeyboardControllerModule } from "react-native-keyboard-controller";
|
|
24
|
+
/** No-op imperative module: there is no IME to dismiss or preload on web. */
|
|
25
|
+
export declare const KeyboardController: KeyboardControllerModule;
|
|
26
|
+
/**
|
|
27
|
+
* Applies `selector` to a permanently "keyboard hidden" state.
|
|
28
|
+
*
|
|
29
|
+
* @param selector - Picks the fields a caller needs from the keyboard state.
|
|
30
|
+
* @returns The selected value, or the whole state when no selector is given.
|
|
31
|
+
*/
|
|
32
|
+
export declare function useKeyboardState<T = IKeyboardState>(selector?: (state: IKeyboardState) => T): T;
|
|
33
|
+
/**
|
|
34
|
+
* @returns A stable, empty keyboard context (no focused input layout on web).
|
|
35
|
+
*/
|
|
36
|
+
export declare function useKeyboardContext(): KeyboardAnimationContext;
|
|
37
|
+
type NativeKeyboardAvoidingViewProps = KeyboardAvoidingViewProps & {
|
|
38
|
+
children?: React.ReactNode;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
|
|
42
|
+
* stay identical across platforms) and dropped, since the browser handles
|
|
43
|
+
* viewport resizing itself.
|
|
44
|
+
*/
|
|
45
|
+
export declare function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }: NativeKeyboardAvoidingViewProps): React.CElement<import("react-native").ViewProps, View>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-controller indirection — web variant (inert stubs).
|
|
3
|
+
*
|
|
4
|
+
* `react-native-keyboard-controller` drags a large native animation runtime
|
|
5
|
+
* into every bundle that imports it, and none of that code can run on web.
|
|
6
|
+
* Components therefore import the keyboard API from this module instead of the
|
|
7
|
+
* package: Metro resolves `keyboardController.native.ts` (the real re-exports)
|
|
8
|
+
* on iOS/Android and this file on web, so the package never reaches the web
|
|
9
|
+
* bundle.
|
|
10
|
+
*
|
|
11
|
+
* Both variants are typed against the package's own types — type-only imports
|
|
12
|
+
* are erased at compile time and bundle nothing — so the two files stay
|
|
13
|
+
* interchangeable to the type checker. TypeScript always resolves this `.ts`
|
|
14
|
+
* variant for consumers, which is why the stubs must keep the full native
|
|
15
|
+
* signatures.
|
|
16
|
+
*
|
|
17
|
+
* Every stub here is a plain function that calls no hooks, so the `use*`
|
|
18
|
+
* exports are safe from any call site (rules of hooks can't be violated by a
|
|
19
|
+
* function that has no hook calls of its own).
|
|
20
|
+
*/
|
|
21
|
+
import React from "react";
|
|
22
|
+
import { View } from "react-native";
|
|
23
|
+
/** There is no software keyboard to observe on web, so state is constant. */
|
|
24
|
+
const HIDDEN_KEYBOARD_STATE = {
|
|
25
|
+
isVisible: false,
|
|
26
|
+
height: 0,
|
|
27
|
+
duration: 0,
|
|
28
|
+
timestamp: 0,
|
|
29
|
+
target: -1,
|
|
30
|
+
type: "default",
|
|
31
|
+
appearance: "light",
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Inert keyboard context.
|
|
35
|
+
*
|
|
36
|
+
* The real context exposes animation values that only exist alongside the
|
|
37
|
+
* native module, so this is a minimal stand-in cast to the package's type: the
|
|
38
|
+
* only field consumers read on web-capable paths is `layout.value`. It is a
|
|
39
|
+
* module-level singleton because `DismissKeyboard` puts the context object in a
|
|
40
|
+
* `useCallback` dependency list — a fresh object per call would invalidate it
|
|
41
|
+
* on every render.
|
|
42
|
+
*/
|
|
43
|
+
const INERT_KEYBOARD_CONTEXT = {
|
|
44
|
+
enabled: false,
|
|
45
|
+
layout: { value: null },
|
|
46
|
+
update: () => Promise.resolve(),
|
|
47
|
+
setKeyboardHandlers: () => () => undefined,
|
|
48
|
+
setInputHandlers: () => () => undefined,
|
|
49
|
+
setEnabled: () => undefined,
|
|
50
|
+
};
|
|
51
|
+
/** No-op imperative module: there is no IME to dismiss or preload on web. */
|
|
52
|
+
export const KeyboardController = {
|
|
53
|
+
setDefaultMode: () => undefined,
|
|
54
|
+
setInputMode: () => undefined,
|
|
55
|
+
preload: () => undefined,
|
|
56
|
+
dismiss: () => Promise.resolve(),
|
|
57
|
+
setFocusTo: () => undefined,
|
|
58
|
+
isVisible: () => false,
|
|
59
|
+
state: () => HIDDEN_KEYBOARD_STATE,
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Applies `selector` to a permanently "keyboard hidden" state.
|
|
63
|
+
*
|
|
64
|
+
* @param selector - Picks the fields a caller needs from the keyboard state.
|
|
65
|
+
* @returns The selected value, or the whole state when no selector is given.
|
|
66
|
+
*/
|
|
67
|
+
export function useKeyboardState(selector) {
|
|
68
|
+
return selector ? selector(HIDDEN_KEYBOARD_STATE) : HIDDEN_KEYBOARD_STATE;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* @returns A stable, empty keyboard context (no focused input layout on web).
|
|
72
|
+
*/
|
|
73
|
+
export function useKeyboardContext() {
|
|
74
|
+
return INERT_KEYBOARD_CONTEXT;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Plain `View` passthrough. Keyboard-avoidance props are accepted (so callers
|
|
78
|
+
* stay identical across platforms) and dropped, since the browser handles
|
|
79
|
+
* viewport resizing itself.
|
|
80
|
+
*/
|
|
81
|
+
export function NativeKeyboardAvoidingView({ behavior, contentContainerStyle, keyboardVerticalOffset, automaticOffset, enabled, children, ...viewProps }) {
|
|
82
|
+
// Dropped rather than forwarded so they never reach the DOM node.
|
|
83
|
+
void behavior;
|
|
84
|
+
void contentContainerStyle;
|
|
85
|
+
void keyboardVerticalOffset;
|
|
86
|
+
void automaticOffset;
|
|
87
|
+
void enabled;
|
|
88
|
+
return React.createElement(View, viewProps, children);
|
|
89
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-controller indirection — native variant.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the real `react-native-keyboard-controller` API under the names the
|
|
5
|
+
* package's components import. See `keyboardController.ts` (the web variant)
|
|
6
|
+
* for why the components go through this module instead of importing the
|
|
7
|
+
* package directly.
|
|
8
|
+
*/
|
|
9
|
+
export { KeyboardController, KeyboardAvoidingView as NativeKeyboardAvoidingView, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard-controller indirection — native variant.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports the real `react-native-keyboard-controller` API under the names the
|
|
5
|
+
* package's components import. See `keyboardController.ts` (the web variant)
|
|
6
|
+
* for why the components go through this module instead of importing the
|
|
7
|
+
* package directly.
|
|
8
|
+
*/
|
|
9
|
+
export { KeyboardController, KeyboardAvoidingView as NativeKeyboardAvoidingView, useKeyboardContext, useKeyboardState, } from "react-native-keyboard-controller";
|
|
@@ -86,5 +86,39 @@ export interface Colors {
|
|
|
86
86
|
light: Theme;
|
|
87
87
|
dark: Theme;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Raw hex/rgba theme colors, always literal strings on every platform. Use
|
|
91
|
+
* these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
|
|
92
|
+
* color parsing/math — and for generating the CSS variable definitions below.
|
|
93
|
+
*/
|
|
94
|
+
export declare const rawThemeColors: {
|
|
95
|
+
light: ThemeColors;
|
|
96
|
+
dark: ThemeColors;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Resolves a theme color value to a literal color string. On web, theme
|
|
100
|
+
* colors are `var(--c-*)` references; this maps one back to the given
|
|
101
|
+
* scheme's raw hex/rgba. Literal inputs (including app override colors)
|
|
102
|
+
* pass through unchanged.
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveRawColor(color: string, scheme: "light" | "dark"): string;
|
|
105
|
+
/**
|
|
106
|
+
* CSS custom-property definitions for every semantic theme color, for
|
|
107
|
+
* embedding in a web app's `+html.tsx` global `<style>`. Each token gets
|
|
108
|
+
* `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
|
|
109
|
+
* comma-separated RGB triplet, alpha stripped — `withAlpha` builds
|
|
110
|
+
* `rgba(var(--c-x-rgb), a)` from it).
|
|
111
|
+
*
|
|
112
|
+
* Light values are defined on `:root`; dark values apply under
|
|
113
|
+
* `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
|
|
114
|
+
* paint before any theme script stamps `data-theme`.
|
|
115
|
+
*
|
|
116
|
+
* Apps that override brand colors can pass those overrides so the emitted
|
|
117
|
+
* CSS matches their theme.
|
|
118
|
+
*/
|
|
119
|
+
export declare function getThemeCssVariables(overrides?: {
|
|
120
|
+
light?: Partial<ThemeColors>;
|
|
121
|
+
dark?: Partial<ThemeColors>;
|
|
122
|
+
}): string;
|
|
89
123
|
export declare const colors: Colors;
|
|
90
124
|
export { palette };
|
package/dist/constants/colors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Platform } from "react-native";
|
|
1
2
|
import { navigationFonts } from "./fonts.js";
|
|
2
3
|
/**
|
|
3
4
|
* Raw color palette - use semantic colors in components instead
|
|
@@ -124,9 +125,98 @@ const darkTheme = {
|
|
|
124
125
|
notification: palette.teal400,
|
|
125
126
|
},
|
|
126
127
|
};
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Raw hex/rgba theme colors, always literal strings on every platform. Use
|
|
130
|
+
* these for sinks that cannot take CSS `var()` — e.g. `<meta name="theme-color">`,
|
|
131
|
+
* color parsing/math — and for generating the CSS variable definitions below.
|
|
132
|
+
*/
|
|
133
|
+
export const rawThemeColors = {
|
|
134
|
+
light: lightTheme.colors,
|
|
135
|
+
dark: darkTheme.colors,
|
|
130
136
|
};
|
|
137
|
+
const themeColorTokens = Object.keys(lightTheme.colors);
|
|
138
|
+
// `surfaceSunken` → `--c-surface-sunken`
|
|
139
|
+
function cssVarName(token) {
|
|
140
|
+
return `--c-${token.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`;
|
|
141
|
+
}
|
|
142
|
+
// Reverse lookup: "var(--c-surface-sunken)" → "surfaceSunken"
|
|
143
|
+
const varToToken = new Map(themeColorTokens.map((token) => [`var(${cssVarName(token)})`, token]));
|
|
144
|
+
/**
|
|
145
|
+
* Resolves a theme color value to a literal color string. On web, theme
|
|
146
|
+
* colors are `var(--c-*)` references; this maps one back to the given
|
|
147
|
+
* scheme's raw hex/rgba. Literal inputs (including app override colors)
|
|
148
|
+
* pass through unchanged.
|
|
149
|
+
*/
|
|
150
|
+
export function resolveRawColor(color, scheme) {
|
|
151
|
+
const token = varToToken.get(color);
|
|
152
|
+
return token ? rawThemeColors[scheme][token] : color;
|
|
153
|
+
}
|
|
154
|
+
// "R, G, B" from "#RRGGBB", "#RGB", or "rgb(a)(R, G, B[, A])" (alpha dropped).
|
|
155
|
+
function toRgbTriplet(value) {
|
|
156
|
+
const rgbaMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
|
|
157
|
+
if (rgbaMatch)
|
|
158
|
+
return `${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}`;
|
|
159
|
+
let hex = value.replace(/^#/, "");
|
|
160
|
+
if (hex.length === 3)
|
|
161
|
+
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
|
162
|
+
const match = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
163
|
+
if (!match)
|
|
164
|
+
return null;
|
|
165
|
+
return `${parseInt(match[1], 16)}, ${parseInt(match[2], 16)}, ${parseInt(match[3], 16)}`;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* CSS custom-property definitions for every semantic theme color, for
|
|
169
|
+
* embedding in a web app's `+html.tsx` global `<style>`. Each token gets
|
|
170
|
+
* `--c-<kebab-token>` (the color value) and `--c-<kebab-token>-rgb` (a
|
|
171
|
+
* comma-separated RGB triplet, alpha stripped — `withAlpha` builds
|
|
172
|
+
* `rgba(var(--c-x-rgb), a)` from it).
|
|
173
|
+
*
|
|
174
|
+
* Light values are defined on `:root`; dark values apply under
|
|
175
|
+
* `html[data-theme="dark"]`, with a `prefers-color-scheme` fallback for the
|
|
176
|
+
* paint before any theme script stamps `data-theme`.
|
|
177
|
+
*
|
|
178
|
+
* Apps that override brand colors can pass those overrides so the emitted
|
|
179
|
+
* CSS matches their theme.
|
|
180
|
+
*/
|
|
181
|
+
export function getThemeCssVariables(overrides) {
|
|
182
|
+
const declarations = (scheme) => themeColorTokens
|
|
183
|
+
.map((token) => {
|
|
184
|
+
const value = overrides?.[scheme]?.[token] ?? rawThemeColors[scheme][token];
|
|
185
|
+
const name = cssVarName(token);
|
|
186
|
+
const rgb = toRgbTriplet(value);
|
|
187
|
+
return `${name}: ${value};${rgb ? ` ${name}-rgb: ${rgb};` : ""}`;
|
|
188
|
+
})
|
|
189
|
+
.join("\n ");
|
|
190
|
+
return `
|
|
191
|
+
:root {
|
|
192
|
+
${declarations("light")}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@media (prefers-color-scheme: dark) {
|
|
196
|
+
html:not([data-theme]) {
|
|
197
|
+
${declarations("dark")}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
html[data-theme="dark"] {
|
|
202
|
+
${declarations("dark")}
|
|
203
|
+
}
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
// On web, semantic theme colors resolve to CSS custom properties so the
|
|
207
|
+
// build-time exported HTML is theme-agnostic: the first frame paints in the
|
|
208
|
+
// visitor's scheme purely from CSS, before any JS runs. Native keeps literal
|
|
209
|
+
// values (no CSS engine). The `navigation` maps stay literal on both
|
|
210
|
+
// platforms: @react-navigation internals color-parse their theme values.
|
|
211
|
+
const webVarColors = Object.fromEntries(themeColorTokens.map((token) => [token, `var(${cssVarName(token)})`]));
|
|
212
|
+
export const colors = Platform.OS === "web"
|
|
213
|
+
? {
|
|
214
|
+
light: { ...lightTheme, colors: { ...webVarColors } },
|
|
215
|
+
dark: { ...darkTheme, colors: { ...webVarColors } },
|
|
216
|
+
}
|
|
217
|
+
: {
|
|
218
|
+
light: lightTheme,
|
|
219
|
+
dark: darkTheme,
|
|
220
|
+
};
|
|
131
221
|
// Export palette for rare one-off cases
|
|
132
222
|
export { palette };
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spacing Constants
|
|
3
3
|
*
|
|
4
|
-
* A consistent spacing scale based on an 8px base unit
|
|
5
|
-
*
|
|
4
|
+
* A consistent spacing scale based on an 8px base unit, plus a small set of
|
|
5
|
+
* density tokens that components reach for when composing rows, surfaces, and
|
|
6
|
+
* screens. Tune the density tokens to shift the whole library's feel without
|
|
7
|
+
* touching the base scale.
|
|
6
8
|
*/
|
|
7
9
|
export declare const spacing: {
|
|
8
10
|
readonly base: 8;
|
|
9
11
|
readonly xxs: 2;
|
|
10
12
|
readonly xs: 4;
|
|
11
13
|
readonly sm: 8;
|
|
14
|
+
readonly smd: 12;
|
|
12
15
|
readonly md: 16;
|
|
16
|
+
readonly mdl: 20;
|
|
13
17
|
readonly lg: 24;
|
|
14
18
|
readonly xl: 32;
|
|
15
19
|
readonly xxl: 48;
|
|
@@ -17,12 +21,18 @@ export declare const spacing: {
|
|
|
17
21
|
readonly gutter: 16;
|
|
18
22
|
readonly gutterVertical: 24;
|
|
19
23
|
readonly screenPadding: 16;
|
|
24
|
+
readonly sectionSpacing: 24;
|
|
25
|
+
readonly rowPaddingY: 10;
|
|
26
|
+
readonly rowPaddingX: 16;
|
|
27
|
+
readonly rowGap: 12;
|
|
28
|
+
readonly rowMinHeight: 40;
|
|
29
|
+
readonly formRowMinHeight: 32;
|
|
30
|
+
readonly cardPadding: 16;
|
|
31
|
+
readonly dialogPadding: 20;
|
|
32
|
+
readonly touchTarget: 44;
|
|
20
33
|
readonly buttonPadding: 10;
|
|
21
34
|
readonly inputPadding: 10;
|
|
22
|
-
readonly cardPadding: 16;
|
|
23
|
-
readonly sectionSpacing: 32;
|
|
24
35
|
readonly listItemSpacing: 8;
|
|
25
|
-
readonly touchTarget: 44;
|
|
26
36
|
readonly radiusNone: 0;
|
|
27
37
|
readonly radiusXs: 4;
|
|
28
38
|
readonly radiusSm: 8;
|
|
@@ -37,5 +47,5 @@ export declare const spacing: {
|
|
|
37
47
|
readonly iconLg: 32;
|
|
38
48
|
readonly iconXl: 48;
|
|
39
49
|
};
|
|
40
|
-
export declare const base: 8, xxs: 2, xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16,
|
|
50
|
+
export declare const base: 8, xxs: 2, xs: 4, sm: 8, smd: 12, md: 16, mdl: 20, lg: 24, xl: 32, xxl: 48, xxxl: 64, gutter: 16, gutterVertical: 24, screenPadding: 16, sectionSpacing: 24, rowPaddingY: 10, rowPaddingX: 16, rowGap: 12, rowMinHeight: 40, formRowMinHeight: 32, cardPadding: 16, dialogPadding: 20, touchTarget: 44, buttonPadding: 10, inputPadding: 10, listItemSpacing: 8, radiusNone: 0, radiusXs: 4, radiusSm: 8, radiusMd: 12, radiusLg: 14, radiusXl: 18, radius2xl: 24, radiusFull: 9999, iconXs: 12, iconSm: 16, iconMd: 24, iconLg: 32, iconXl: 48;
|
|
41
51
|
export declare const space: (multiplier: number) => number;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Spacing Constants
|
|
3
3
|
*
|
|
4
|
-
* A consistent spacing scale based on an 8px base unit
|
|
5
|
-
*
|
|
4
|
+
* A consistent spacing scale based on an 8px base unit, plus a small set of
|
|
5
|
+
* density tokens that components reach for when composing rows, surfaces, and
|
|
6
|
+
* screens. Tune the density tokens to shift the whole library's feel without
|
|
7
|
+
* touching the base scale.
|
|
6
8
|
*/
|
|
7
9
|
export const spacing = {
|
|
8
10
|
// Base unit: 8px
|
|
@@ -11,7 +13,9 @@ export const spacing = {
|
|
|
11
13
|
xxs: 2, // 2px - Minimal spacing
|
|
12
14
|
xs: 4, // 4px - Extra small
|
|
13
15
|
sm: 8, // 8px - Small (1 unit)
|
|
16
|
+
smd: 12, // 12px - Between small and medium (1.5 units); compact gaps and row padding
|
|
14
17
|
md: 16, // 16px - Medium (2 units)
|
|
18
|
+
mdl: 20, // 20px - Between medium and large (2.5 units); dialog and sheet padding
|
|
15
19
|
lg: 24, // 24px - Large (3 units)
|
|
16
20
|
xl: 32, // 32px - Extra large (4 units)
|
|
17
21
|
xxl: 48, // 48px - 2x extra large (6 units)
|
|
@@ -20,13 +24,20 @@ export const spacing = {
|
|
|
20
24
|
gutter: 16, // Standard horizontal gutter
|
|
21
25
|
gutterVertical: 24, // Standard vertical spacing
|
|
22
26
|
screenPadding: 16, // Default screen edge padding
|
|
27
|
+
sectionSpacing: 24, // Space between major sections / grouped lists
|
|
28
|
+
// Density tokens — rows, surfaces, overlays
|
|
29
|
+
rowPaddingY: 10, // Vertical padding for list / settings rows
|
|
30
|
+
rowPaddingX: 16, // Horizontal padding for list / settings rows
|
|
31
|
+
rowGap: 12, // Gap between media, content, and actions in a row
|
|
32
|
+
rowMinHeight: 40, // Visual min height for rows on pointer surfaces (web)
|
|
33
|
+
formRowMinHeight: 32, // Visual min height for checkbox / radio rows on web
|
|
34
|
+
cardPadding: 16, // Default card / stat card padding
|
|
35
|
+
dialogPadding: 20, // Default dialog / alert dialog padding
|
|
36
|
+
touchTarget: 44, // Minimum comfortable native touch target
|
|
23
37
|
// Component-specific
|
|
24
38
|
buttonPadding: 10, // Default button padding
|
|
25
39
|
inputPadding: 10, // Default input padding
|
|
26
|
-
cardPadding: 16, // Default card padding
|
|
27
|
-
sectionSpacing: 32, // Space between major sections
|
|
28
40
|
listItemSpacing: 8, // Space between list items
|
|
29
|
-
touchTarget: 44, // Minimum comfortable native touch target
|
|
30
41
|
// Border radius — shadcn-inspired scale (radiusMd = 12px default)
|
|
31
42
|
radiusNone: 0,
|
|
32
43
|
radiusXs: 4,
|
|
@@ -44,6 +55,6 @@ export const spacing = {
|
|
|
44
55
|
iconXl: 48,
|
|
45
56
|
};
|
|
46
57
|
// Export individual constants for convenience
|
|
47
|
-
export const { base, xxs, xs, sm, md, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding,
|
|
58
|
+
export const { base, xxs, xs, sm, smd, md, mdl, lg, xl, xxl, xxxl, gutter, gutterVertical, screenPadding, sectionSpacing, rowPaddingY, rowPaddingX, rowGap, rowMinHeight, formRowMinHeight, cardPadding, dialogPadding, touchTarget, buttonPadding, inputPadding, listItemSpacing, radiusNone, radiusXs, radiusSm, radiusMd, radiusLg, radiusXl, radius2xl, radiusFull, iconXs, iconSm, iconMd, iconLg, iconXl, } = spacing;
|
|
48
59
|
// Helper function to multiply base spacing
|
|
49
60
|
export const space = (multiplier) => spacing.base * multiplier;
|
|
@@ -29,11 +29,13 @@ type WindowDimensions = {
|
|
|
29
29
|
* Provides a consistent way to access window dimensions and screen size
|
|
30
30
|
* information across mobile and web.
|
|
31
31
|
*
|
|
32
|
-
* On web the first render
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* On web the first render can't read the window: server-side there is no DOM,
|
|
33
|
+
* and reading it during the browser's first render would disagree with the
|
|
34
|
+
* markup it hydrates. The width comes from `SsrViewportContext` when the host
|
|
35
|
+
* provides a per-request value (server render + first client render must
|
|
36
|
+
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
|
|
37
|
+
* A post-mount effect swaps in the real viewport and follows resize from
|
|
38
|
+
* there.
|
|
37
39
|
*/
|
|
38
40
|
export declare const useDimensions: () => WindowDimensions;
|
|
39
41
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { use, useEffect, useState } from "react";
|
|
2
2
|
import { Platform, useWindowDimensions } from "react-native";
|
|
3
|
+
import { SsrViewportContext } from "../state/SsrViewportContext.js";
|
|
3
4
|
/**
|
|
4
5
|
* Viewport the first web render is computed from, before the real window can
|
|
5
6
|
* be read. Desktop is chosen because:
|
|
@@ -33,24 +34,41 @@ const calculateDimensionFlags = (width, height) => {
|
|
|
33
34
|
isLargeScreen: width > SCREEN_SIZES.MEDIUM,
|
|
34
35
|
};
|
|
35
36
|
};
|
|
37
|
+
// Persist the real viewport width as a cookie on first mount so subsequent
|
|
38
|
+
// SSR requests can render at the user's actual layout (no reflow on repeat
|
|
39
|
+
// visits). The server reads this cookie via resolveSsrViewportWidth in
|
|
40
|
+
// server/lib/ssrViewport.ts.
|
|
41
|
+
const SSR_VIEWPORT_COOKIE = "mrmeg-vw";
|
|
42
|
+
const SSR_VIEWPORT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
|
43
|
+
function writeViewportCookie(width) {
|
|
44
|
+
if (typeof document === "undefined")
|
|
45
|
+
return;
|
|
46
|
+
// Round to nearest 10 so resize-driven writes don't bust HTTP caching on
|
|
47
|
+
// every pixel of horizontal movement.
|
|
48
|
+
const rounded = Math.round(width / 10) * 10;
|
|
49
|
+
document.cookie = `${SSR_VIEWPORT_COOKIE}=${rounded}; path=/; max-age=${SSR_VIEWPORT_COOKIE_MAX_AGE}; SameSite=Lax`;
|
|
50
|
+
}
|
|
36
51
|
/**
|
|
37
52
|
* Provides a consistent way to access window dimensions and screen size
|
|
38
53
|
* information across mobile and web.
|
|
39
54
|
*
|
|
40
|
-
* On web the first render
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
55
|
+
* On web the first render can't read the window: server-side there is no DOM,
|
|
56
|
+
* and reading it during the browser's first render would disagree with the
|
|
57
|
+
* markup it hydrates. The width comes from `SsrViewportContext` when the host
|
|
58
|
+
* provides a per-request value (server render + first client render must
|
|
59
|
+
* provide the same one), falling back to `DEFAULT_VIEWPORT_WIDTH` / `_HEIGHT`.
|
|
60
|
+
* A post-mount effect swaps in the real viewport and follows resize from
|
|
61
|
+
* there.
|
|
45
62
|
*/
|
|
46
63
|
export const useDimensions = () => {
|
|
47
64
|
const isWeb = Platform.OS === "web";
|
|
65
|
+
const ssrWidth = use(SsrViewportContext);
|
|
48
66
|
// Native reads come from useWindowDimensions, which subscribes to rotation /
|
|
49
67
|
// split-screen / resize and tears the listener down for us — no manual
|
|
50
68
|
// Dimensions.addEventListener to leak. On web we ignore it and drive layout
|
|
51
|
-
// from the
|
|
69
|
+
// from the seeded frame + the resize listener below.
|
|
52
70
|
const native = useWindowDimensions();
|
|
53
|
-
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
|
|
71
|
+
const [dimensions, setDimensions] = useState(() => calculateDimensionFlags(ssrWidth ?? DEFAULT_VIEWPORT_WIDTH, DEFAULT_VIEWPORT_HEIGHT));
|
|
54
72
|
// Web: read the real viewport after mount and follow resize events. Keeping
|
|
55
73
|
// this in an effect (not render) keeps the first render identical to the
|
|
56
74
|
// exported markup it hydrates.
|
|
@@ -59,6 +77,7 @@ export const useDimensions = () => {
|
|
|
59
77
|
return;
|
|
60
78
|
const syncFromWindow = () => {
|
|
61
79
|
setDimensions(calculateDimensionFlags(window.innerWidth, window.innerHeight));
|
|
80
|
+
writeViewportCookie(window.innerWidth);
|
|
62
81
|
};
|
|
63
82
|
syncFromWindow();
|
|
64
83
|
window.addEventListener("resize", syncFromWindow);
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the Feather icon font in expo-font's server store during
|
|
3
|
+
* export-time rendering (`expo export` renders every route's HTML shell in
|
|
4
|
+
* Node, where the module-scope client load above is guarded off).
|
|
5
|
+
*
|
|
6
|
+
* Must be called from a **render body** (see `useResources` below): expo-font
|
|
7
|
+
* scopes its server store per render context, so a module-scope registration
|
|
8
|
+
* lands in at most one route's shell. Registering during render means every
|
|
9
|
+
* exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
|
|
10
|
+
* without it the shell contains an EMPTY icon text node while the client's
|
|
11
|
+
* first render (font pre-loaded above) contains the glyph, and React throws
|
|
12
|
+
* hydration error #418 on every cold load and regenerates the tree.
|
|
13
|
+
*
|
|
14
|
+
* No-op on native and on client web (both already covered: native by the
|
|
15
|
+
* effect below, client web by the module-scope call above).
|
|
16
|
+
*/
|
|
17
|
+
export declare function ensureIconFontRegistered(): void;
|
|
1
18
|
interface LoadResourcesResult {
|
|
2
19
|
loaded: boolean;
|
|
3
20
|
error: Error | null;
|
|
@@ -15,6 +15,32 @@ import { useThemeStore } from "../state/themeStore.js";
|
|
|
15
15
|
if (typeof window !== "undefined") {
|
|
16
16
|
void Font.loadAsync(Feather.font);
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Registers the Feather icon font in expo-font's server store during
|
|
20
|
+
* export-time rendering (`expo export` renders every route's HTML shell in
|
|
21
|
+
* Node, where the module-scope client load above is guarded off).
|
|
22
|
+
*
|
|
23
|
+
* Must be called from a **render body** (see `useResources` below): expo-font
|
|
24
|
+
* scopes its server store per render context, so a module-scope registration
|
|
25
|
+
* lands in at most one route's shell. Registering during render means every
|
|
26
|
+
* exported shell emits the `@font-face` rule and renders `<Icon>` glyphs —
|
|
27
|
+
* without it the shell contains an EMPTY icon text node while the client's
|
|
28
|
+
* first render (font pre-loaded above) contains the glyph, and React throws
|
|
29
|
+
* hydration error #418 on every cold load and regenerates the tree.
|
|
30
|
+
*
|
|
31
|
+
* No-op on native and on client web (both already covered: native by the
|
|
32
|
+
* effect below, client web by the module-scope call above).
|
|
33
|
+
*/
|
|
34
|
+
export function ensureIconFontRegistered() {
|
|
35
|
+
if (Platform.OS !== "web" || typeof window !== "undefined")
|
|
36
|
+
return;
|
|
37
|
+
if (Font.isLoaded("feather"))
|
|
38
|
+
return;
|
|
39
|
+
// Synchronous on server-web (expo-font routes to registerStaticFont). No
|
|
40
|
+
// `void`/try-catch: a throw here means we're outside a render scope, which
|
|
41
|
+
// must be loud.
|
|
42
|
+
Font.loadAsync(Feather.font);
|
|
43
|
+
}
|
|
18
44
|
// The four static Inter weights StyledText's native family keys point at
|
|
19
45
|
// (see constants/fonts.ts). Native-only: web never renders these family names
|
|
20
46
|
// ("Inter_400Regular" etc.) — fontFamilies.sansSerif resolves every weight to
|
|
@@ -68,6 +94,9 @@ function ensureWebFontStylesheet() {
|
|
|
68
94
|
* un-download Inter. The Feather icon font always loads.
|
|
69
95
|
*/
|
|
70
96
|
export const useResources = () => {
|
|
97
|
+
// Export-time renders need the icon font registered per render scope; the
|
|
98
|
+
// module-scope call only reaches the client. See ensureIconFontRegistered.
|
|
99
|
+
ensureIconFontRegistered();
|
|
71
100
|
const [loaded, setLoaded] = useState(false);
|
|
72
101
|
const [error, setError] = useState(null);
|
|
73
102
|
useEffect(() => {
|
package/dist/hooks/useTheme.d.ts
CHANGED
|
@@ -39,6 +39,24 @@ export declare function useTheme(): ExtendedColorScheme & {
|
|
|
39
39
|
setTheme: (theme: "system" | "light" | "dark") => void;
|
|
40
40
|
currentTheme: "system" | "light" | "dark";
|
|
41
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Generates an alpha-modified version of a color
|
|
44
|
+
* @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
|
|
45
|
+
* @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
|
|
46
|
+
* @returns rgba color string with the specified alpha
|
|
47
|
+
*
|
|
48
|
+
* On web, theme colors are `var(--c-*)` references. Those rewrite onto the
|
|
49
|
+
* companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
|
|
50
|
+
* result is pure CSS that re-themes with the variable — usable from
|
|
51
|
+
* module-scope style factories, which run once for both schemes.
|
|
52
|
+
*
|
|
53
|
+
* The result is wrapped in `var(--c-unset, …)` — a custom property that is
|
|
54
|
+
* never defined, so browsers always use the fallback. react-native-web's
|
|
55
|
+
* style compiler only passes a color value through verbatim when it starts
|
|
56
|
+
* with `var(`; a bare `rgba(var(…))` fails its color parser and the
|
|
57
|
+
* declaration is silently dropped.
|
|
58
|
+
*/
|
|
59
|
+
export declare function withAlpha(color: string, alpha: number): string;
|
|
42
60
|
/**
|
|
43
61
|
* Style factory context passed to the createStyles callback
|
|
44
62
|
*/
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo } from "react";
|
|
2
|
-
import { colors } from "../constants/colors.js";
|
|
2
|
+
import { colors, resolveRawColor } from "../constants/colors.js";
|
|
3
3
|
import { Platform, StyleSheet } from "react-native";
|
|
4
4
|
import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
|
|
5
5
|
import { useThemeColorScope } from "../state/themeColorScope.js";
|
|
@@ -170,10 +170,12 @@ export function useTheme() {
|
|
|
170
170
|
boxShadow: `0 0 0 ${offset}px ${theme.colors.background}, 0 0 0 ${offset + 2}px ${theme.colors.ring}`,
|
|
171
171
|
};
|
|
172
172
|
}, [theme.colors.background, theme.colors.ring]);
|
|
173
|
-
// Helper to calculate contrast ratio between two colors
|
|
173
|
+
// Helper to calculate contrast ratio between two colors.
|
|
174
|
+
// On web theme colors are `var(--c-*)` strings, which cannot be parsed —
|
|
175
|
+
// resolve them to the active scheme's literal value first.
|
|
174
176
|
const getContrastRatio = useCallback((color1, color2) => {
|
|
175
|
-
const rgb1 = parseColor(color1);
|
|
176
|
-
const rgb2 = parseColor(color2);
|
|
177
|
+
const rgb1 = parseColor(resolveRawColor(color1, effectiveScheme));
|
|
178
|
+
const rgb2 = parseColor(resolveRawColor(color2, effectiveScheme));
|
|
177
179
|
if (!rgb1 || !rgb2) {
|
|
178
180
|
console.error("Invalid colors for contrast ratio:", color1, color2);
|
|
179
181
|
return 1; // Default to minimum contrast
|
|
@@ -181,13 +183,25 @@ export function useTheme() {
|
|
|
181
183
|
const lum1 = calculateLuminance(rgb1[0], rgb1[1], rgb1[2]);
|
|
182
184
|
const lum2 = calculateLuminance(rgb2[0], rgb2[1], rgb2[2]);
|
|
183
185
|
return calculateContrastRatio(lum1, lum2);
|
|
184
|
-
}, []);
|
|
186
|
+
}, [effectiveScheme]);
|
|
185
187
|
// Cached version of getContrastingColor for performance
|
|
186
|
-
// Uses module-level cache to prevent memory leak
|
|
188
|
+
// Uses module-level cache to prevent memory leak. `var(--c-*)` inputs are
|
|
189
|
+
// resolved per scheme BEFORE the cache so keys stay scheme-specific, and
|
|
190
|
+
// the winning candidate is returned as originally passed (a `var()` input
|
|
191
|
+
// stays a `var()` output, so the picked color keeps re-theming via CSS).
|
|
187
192
|
const getCachedContrastingColor = useCallback((backgroundColor, color1, color2) => {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
const bg = resolveRawColor(backgroundColor, effectiveScheme);
|
|
194
|
+
const c1 = color1 === undefined ? undefined : resolveRawColor(color1, effectiveScheme);
|
|
195
|
+
const c2 = color2 === undefined ? undefined : resolveRawColor(color2, effectiveScheme);
|
|
196
|
+
const cacheKey = `${bg}-${c1}-${c2}`;
|
|
197
|
+
const winner = getCachedOrCompute(cacheKey, () => getBetterContrast(bg, c1, c2));
|
|
198
|
+
if (color1 !== undefined && winner === c1)
|
|
199
|
+
return color1;
|
|
200
|
+
if (color2 !== undefined && winner === c2)
|
|
201
|
+
return color2;
|
|
202
|
+
return winner;
|
|
203
|
+
}, [effectiveScheme]);
|
|
204
|
+
const getResolvedTextColorForBackground = useCallback((backgroundColor) => getTextColorForBackground(resolveRawColor(backgroundColor, effectiveScheme)), [effectiveScheme]);
|
|
191
205
|
return useMemo(() => ({
|
|
192
206
|
theme,
|
|
193
207
|
scheme: theme.dark ? "dark" : "light",
|
|
@@ -197,13 +211,14 @@ export function useTheme() {
|
|
|
197
211
|
setTheme,
|
|
198
212
|
currentTheme: userTheme,
|
|
199
213
|
getContrastingColor: getCachedContrastingColor,
|
|
200
|
-
getTextColorForBackground,
|
|
214
|
+
getTextColorForBackground: getResolvedTextColorForBackground,
|
|
201
215
|
withAlpha,
|
|
202
216
|
getContrastRatio,
|
|
203
217
|
}), [
|
|
204
218
|
getCachedContrastingColor,
|
|
205
219
|
getContrastRatio,
|
|
206
220
|
getFocusRingStyle,
|
|
221
|
+
getResolvedTextColorForBackground,
|
|
207
222
|
getShadowStyle,
|
|
208
223
|
setTheme,
|
|
209
224
|
theme,
|
|
@@ -326,11 +341,26 @@ function getTextColorForBackground(backgroundColor) {
|
|
|
326
341
|
}
|
|
327
342
|
/**
|
|
328
343
|
* Generates an alpha-modified version of a color
|
|
329
|
-
* @param color - The color to modify (hex or
|
|
330
|
-
* @param alpha - Alpha value between 0 and 1
|
|
344
|
+
* @param color - The color to modify (hex, rgba, or a `var(--c-*)` theme color)
|
|
345
|
+
* @param alpha - Alpha value between 0 and 1 (replaces any existing alpha)
|
|
331
346
|
* @returns rgba color string with the specified alpha
|
|
347
|
+
*
|
|
348
|
+
* On web, theme colors are `var(--c-*)` references. Those rewrite onto the
|
|
349
|
+
* companion `--c-*-rgb` triplet variable (see `getThemeCssVariables`), so the
|
|
350
|
+
* result is pure CSS that re-themes with the variable — usable from
|
|
351
|
+
* module-scope style factories, which run once for both schemes.
|
|
352
|
+
*
|
|
353
|
+
* The result is wrapped in `var(--c-unset, …)` — a custom property that is
|
|
354
|
+
* never defined, so browsers always use the fallback. react-native-web's
|
|
355
|
+
* style compiler only passes a color value through verbatim when it starts
|
|
356
|
+
* with `var(`; a bare `rgba(var(…))` fails its color parser and the
|
|
357
|
+
* declaration is silently dropped.
|
|
332
358
|
*/
|
|
333
|
-
function withAlpha(color, alpha) {
|
|
359
|
+
export function withAlpha(color, alpha) {
|
|
360
|
+
const varMatch = /^var\((--c-[a-z0-9-]+)\)$/.exec(color);
|
|
361
|
+
if (varMatch) {
|
|
362
|
+
return `var(--c-unset, rgba(var(${varMatch[1]}-rgb), ${alpha}))`;
|
|
363
|
+
}
|
|
334
364
|
const rgb = parseColor(color);
|
|
335
365
|
if (!rgb) {
|
|
336
366
|
console.error("Invalid color for alpha:", color);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request SSR viewport width. Consumed by `useDimensions` to seed the
|
|
3
|
+
* server render and the browser's FIRST render, so a server that detected a
|
|
4
|
+
* phone lays the tree out at phone width and hydration matches byte-for-byte.
|
|
5
|
+
*
|
|
6
|
+
* `null` (the default, when no provider is mounted) means "no per-request
|
|
7
|
+
* signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
|
|
8
|
+
* host app is expected to provide the same value on the server and on the
|
|
9
|
+
* client's first render — both derived from signals both sides can read
|
|
10
|
+
* (cookie / User-Agent), never from `window`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const SsrViewportContext: import("react").Context<number | null>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createContext } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Per-request SSR viewport width. Consumed by `useDimensions` to seed the
|
|
4
|
+
* server render and the browser's FIRST render, so a server that detected a
|
|
5
|
+
* phone lays the tree out at phone width and hydration matches byte-for-byte.
|
|
6
|
+
*
|
|
7
|
+
* `null` (the default, when no provider is mounted) means "no per-request
|
|
8
|
+
* signal" and `useDimensions` falls back to `DEFAULT_VIEWPORT_WIDTH`. The
|
|
9
|
+
* host app is expected to provide the same value on the server and on the
|
|
10
|
+
* client's first render — both derived from signals both sides can read
|
|
11
|
+
* (cookie / User-Agent), never from `window`.
|
|
12
|
+
*/
|
|
13
|
+
export const SsrViewportContext = createContext(null);
|
package/dist/state/index.d.ts
CHANGED
package/dist/state/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrmeg/expo-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
|
|
6
6
|
"keywords": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"scripts": {
|
|
84
84
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
85
85
|
"test": "jest --config ../../jest.config.js packages/ui/src --runInBand --watchman=false && bun run check:forbidden-imports",
|
|
86
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-
|
|
86
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-package-esm.mjs ui && bun run check:forbidden-imports",
|
|
87
87
|
"check:forbidden-imports": "node ../../scripts/check-ui-forbidden-imports.mjs",
|
|
88
88
|
"publish:dry-run": "bun pm pack --dry-run"
|
|
89
89
|
},
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"@rn-primitives/dropdown-menu": "~1.5.2",
|
|
99
99
|
"@rn-primitives/label": "~1.5.2",
|
|
100
100
|
"@rn-primitives/popover": "~1.5.2",
|
|
101
|
-
"@rn-primitives/portal": "~1.5.
|
|
101
|
+
"@rn-primitives/portal": "~1.5.3",
|
|
102
102
|
"@rn-primitives/radio-group": "~1.5.2",
|
|
103
103
|
"@rn-primitives/select": "~1.5.2",
|
|
104
104
|
"@rn-primitives/separator": "~1.5.2",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"zustand": ">=5.0.0 <6.0.0"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@types/react": "~19.2.
|
|
128
|
+
"@types/react": "~19.2.18",
|
|
129
129
|
"typescript": "~6.0.3"
|
|
130
130
|
}
|
|
131
131
|
}
|