@mrmeg/expo-ui 0.5.0 → 0.6.1
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/LLM_USAGE.md +11 -0
- package/README.md +41 -0
- package/dist/components/DropdownMenu.d.ts +13 -10
- package/dist/components/DropdownMenu.js +3 -1
- package/dist/hooks/useTheme.js +16 -9
- package/dist/state/index.d.ts +1 -0
- package/dist/state/index.js +1 -0
- package/dist/state/themeColorScope.d.ts +9 -0
- package/dist/state/themeColorScope.js +31 -0
- package/package.json +1 -1
package/LLM_USAGE.md
CHANGED
|
@@ -149,6 +149,17 @@ OS color-scheme subscription, including web `prefers-color-scheme`. Do not add
|
|
|
149
149
|
app-local Appearance or `matchMedia` listeners for package components; import
|
|
150
150
|
`useTheme()`, `useStyles()`, and `useThemeStore` from `@mrmeg/expo-ui`.
|
|
151
151
|
|
|
152
|
+
`useTheme()` resolves colors in three layers, last wins: package defaults →
|
|
153
|
+
global brand (`useThemeStore.getState().setColors(overrides)`) → scoped
|
|
154
|
+
override (`ThemeColorScope`). Each override is `{ light?, dark? }` of
|
|
155
|
+
`Partial<ThemeColors>`; only the keys you pass are replaced. Call `setColors`
|
|
156
|
+
once to forward the app's brand palette globally. Wrap a subtree in
|
|
157
|
+
`<ThemeColorScope colors={{ light, dark }}>` for transient per-subtree theming
|
|
158
|
+
(user-created palettes, previews, embeds) that must not leak globally — it is
|
|
159
|
+
React context, scoped keys win over the global brand inside it, and nested
|
|
160
|
+
scopes merge (inner wins, outer fills in). With no override at either layer,
|
|
161
|
+
`useTheme()` returns the base theme by reference.
|
|
162
|
+
|
|
152
163
|
## Component Use-Case Index
|
|
153
164
|
|
|
154
165
|
Use this table before creating a new app-local primitive.
|
package/README.md
CHANGED
|
@@ -122,6 +122,47 @@ const { styles } = useStyles(({ theme, spacing, withAlpha }) => ({
|
|
|
122
122
|
}));
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
### Color overrides
|
|
126
|
+
|
|
127
|
+
The package ships a neutral default palette. `useTheme()` resolves colors in
|
|
128
|
+
three layers, last wins:
|
|
129
|
+
|
|
130
|
+
1. **Package defaults** — the built-in zinc/teal palette.
|
|
131
|
+
2. **Global brand** (`setColors`) — your app's one palette, applied everywhere.
|
|
132
|
+
3. **Scoped override** (`ThemeColorScope`) — a per-subtree palette layered on
|
|
133
|
+
top of the brand, for transient theming that should not leak globally.
|
|
134
|
+
|
|
135
|
+
With no override at either layer, `useTheme()` returns the base theme by
|
|
136
|
+
reference — identical to pre-override behavior, with no extra allocation.
|
|
137
|
+
|
|
138
|
+
Forward your app's brand palette once (e.g. from a top-level effect). Each
|
|
139
|
+
scheme is a `Partial<ThemeColors>`, so you only specify the keys you want to
|
|
140
|
+
re-skin:
|
|
141
|
+
|
|
142
|
+
```tsx
|
|
143
|
+
import { useThemeStore } from "@mrmeg/expo-ui/state";
|
|
144
|
+
|
|
145
|
+
useThemeStore.getState().setColors({
|
|
146
|
+
light: { primary: "#7c3aed", accent: "#14b8a6" },
|
|
147
|
+
dark: { primary: "#a78bfa", accent: "#2dd4bf" },
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Use `ThemeColorScope` to override colors for one subtree without touching the
|
|
152
|
+
global theme — a survey with a user-created palette, a preview pane, an embed.
|
|
153
|
+
It is React context, so it applies only inside the provider and unwinds when
|
|
154
|
+
that subtree unmounts. Nested scopes compose (inner keys win, outer fill in),
|
|
155
|
+
and a scoped key wins over the global brand for components inside it:
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
import { ThemeColorScope } from "@mrmeg/expo-ui/state";
|
|
159
|
+
|
|
160
|
+
<ThemeColorScope colors={{ light: surveyColors, dark: surveyColors }}>
|
|
161
|
+
{/* every package component in here renders with the survey palette */}
|
|
162
|
+
<SurveyScreen />
|
|
163
|
+
</ThemeColorScope>
|
|
164
|
+
```
|
|
165
|
+
|
|
125
166
|
Use `StyledText` for theme-aware text:
|
|
126
167
|
|
|
127
168
|
```tsx
|
|
@@ -9,15 +9,6 @@ declare const DropdownMenu: {
|
|
|
9
9
|
} & React.RefAttributes<View>): React.JSX.Element;
|
|
10
10
|
displayName: string;
|
|
11
11
|
};
|
|
12
|
-
declare const DropdownMenuTrigger: {
|
|
13
|
-
({ asChild, onPress: onPressProp, disabled, ref, ...props }: Omit<import("react-native").PressableProps & React.RefAttributes<View>, "ref"> & {
|
|
14
|
-
asChild?: boolean;
|
|
15
|
-
} & {
|
|
16
|
-
onKeyDown?: (ev: React.KeyboardEvent) => void;
|
|
17
|
-
onKeyUp?: (ev: React.KeyboardEvent) => void;
|
|
18
|
-
} & React.RefAttributes<import("@rn-primitives/dropdown-menu").TriggerRef>): React.JSX.Element;
|
|
19
|
-
displayName: string;
|
|
20
|
-
};
|
|
21
12
|
declare const DropdownMenuGroup: {
|
|
22
13
|
({ asChild, ref, ...props }: import("react-native").ViewProps & {
|
|
23
14
|
asChild?: boolean;
|
|
@@ -44,6 +35,18 @@ declare const DropdownMenuRadioGroup: {
|
|
|
44
35
|
} & React.RefAttributes<View>): React.JSX.Element;
|
|
45
36
|
displayName: string;
|
|
46
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* DropdownMenuTrigger Component
|
|
40
|
+
* Wraps the primitive Trigger to default `accessibilityRole="button"`.
|
|
41
|
+
*
|
|
42
|
+
* The underlying @rn-primitives Trigger renders a RN-Web Pressable (or a Slot
|
|
43
|
+
* when `asChild`) without a role, so on web it becomes `role="generic"` —
|
|
44
|
+
* breaking screen-reader semantics and `getByRole("button")` queries. When
|
|
45
|
+
* `asChild` is used, the child's own role still wins (the Slot merges child
|
|
46
|
+
* props over slot props), so this only fills the gap when none is set.
|
|
47
|
+
*/
|
|
48
|
+
type DropdownMenuTriggerProps = DropdownMenuPrimitive.TriggerProps;
|
|
49
|
+
declare function DropdownMenuTrigger({ ...props }: DropdownMenuTriggerProps): import("react/jsx-runtime").JSX.Element;
|
|
47
50
|
/**
|
|
48
51
|
* DropdownMenuSubTrigger Component
|
|
49
52
|
* Trigger for sub-menus with automatic chevron icon
|
|
@@ -117,4 +120,4 @@ interface DropdownMenuShortcutProps {
|
|
|
117
120
|
}
|
|
118
121
|
declare function DropdownMenuShortcut({ style: styleOverride, ...props }: DropdownMenuShortcutProps): import("react/jsx-runtime").JSX.Element;
|
|
119
122
|
export { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, };
|
|
120
|
-
export type { DropdownMenuSubTriggerProps, DropdownMenuSubContentProps, DropdownMenuContentProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuLabelProps, DropdownMenuSeparatorProps, DropdownMenuShortcutProps, };
|
|
123
|
+
export type { DropdownMenuTriggerProps, DropdownMenuSubTriggerProps, DropdownMenuSubContentProps, DropdownMenuContentProps, DropdownMenuItemProps, DropdownMenuCheckboxItemProps, DropdownMenuRadioItemProps, DropdownMenuLabelProps, DropdownMenuSeparatorProps, DropdownMenuShortcutProps, };
|
|
@@ -11,13 +11,15 @@ import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
|
|
11
11
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
12
12
|
// Re-export primitives that don't need styling
|
|
13
13
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
14
|
-
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
15
14
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
|
16
15
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
|
17
16
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|
18
17
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
|
19
18
|
// Platform-specific overlay
|
|
20
19
|
const FullWindowOverlay = Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
|
20
|
+
function DropdownMenuTrigger({ ...props }) {
|
|
21
|
+
return _jsx(DropdownMenuPrimitive.Trigger, { accessibilityRole: "button", ...props });
|
|
22
|
+
}
|
|
21
23
|
function DropdownMenuSubTrigger({ inset = false, children, style: styleOverride, ...props }) {
|
|
22
24
|
const { theme } = useTheme();
|
|
23
25
|
const { open } = DropdownMenuPrimitive.useSubContext();
|
package/dist/hooks/useTheme.js
CHANGED
|
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo } from "react";
|
|
|
2
2
|
import { colors } from "../constants/colors.js";
|
|
3
3
|
import { Platform, StyleSheet } from "react-native";
|
|
4
4
|
import { resolveThemePreference, useThemeStore } from "../state/themeStore.js";
|
|
5
|
+
import { useThemeColorScope } from "../state/themeColorScope.js";
|
|
5
6
|
import { spacing as spacingConstants } from "../constants/spacing.js";
|
|
6
7
|
// Module-level cache for contrast calculations to avoid memory leak
|
|
7
8
|
// and share across components
|
|
@@ -48,23 +49,29 @@ export function useTheme() {
|
|
|
48
49
|
const systemTheme = useThemeStore((s) => s.systemTheme);
|
|
49
50
|
const setTheme = useThemeStore((s) => s.setTheme);
|
|
50
51
|
const colorOverrides = useThemeStore((s) => s.colorOverrides);
|
|
52
|
+
const scoped = useThemeColorScope();
|
|
51
53
|
// Determine which theme to use (user preference or system)
|
|
52
54
|
const effectiveScheme = resolveThemePreference(userTheme, systemTheme);
|
|
53
55
|
const base = colors[effectiveScheme];
|
|
54
|
-
// Layer
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
56
|
+
// Layer overrides on top of the package defaults for the active scheme, in
|
|
57
|
+
// precedence order: package default → global store (app brand) → scoped
|
|
58
|
+
// context (per-subtree, e.g. a survey theme). When neither override is
|
|
59
|
+
// present we return the base theme *by reference* so memoization and identity
|
|
60
|
+
// checks downstream stay stable (and the package behaves exactly as it did
|
|
61
|
+
// before overrides existed).
|
|
62
|
+
const storeOverride = colorOverrides[effectiveScheme]; // global app brand
|
|
63
|
+
const scopedOverride = scoped?.[effectiveScheme]; // subtree override
|
|
59
64
|
const theme = useMemo(() => {
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
const hasStore = storeOverride && Object.keys(storeOverride).length > 0;
|
|
66
|
+
const hasScoped = scopedOverride && Object.keys(scopedOverride).length > 0;
|
|
67
|
+
if (!hasStore && !hasScoped) {
|
|
68
|
+
return base; // identity preserved — no allocation, stable references
|
|
62
69
|
}
|
|
63
70
|
return {
|
|
64
71
|
...base,
|
|
65
|
-
colors: { ...base.colors, ...
|
|
72
|
+
colors: { ...base.colors, ...storeOverride, ...scopedOverride },
|
|
66
73
|
};
|
|
67
|
-
}, [base,
|
|
74
|
+
}, [base, storeOverride, scopedOverride]);
|
|
68
75
|
// Sync theme to DOM so CSS in +html.tsx follows the app's runtime theme
|
|
69
76
|
useEffect(() => {
|
|
70
77
|
if (Platform.OS === "web" && typeof document !== "undefined") {
|
package/dist/state/index.d.ts
CHANGED
package/dist/state/index.js
CHANGED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ColorOverrides } from "./themeStore";
|
|
3
|
+
/** Read the active scoped override (null when not inside a scope). */
|
|
4
|
+
export declare function useThemeColorScope(): ColorOverrides | null;
|
|
5
|
+
export declare function ThemeColorScope({ colors, children, }: {
|
|
6
|
+
/** Per-scheme partial overrides — same shape as `setColors`. */
|
|
7
|
+
colors: ColorOverrides;
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useMemo } from "react";
|
|
3
|
+
/**
|
|
4
|
+
* Per-subtree color overrides, layered on top of the global theme by `useTheme`.
|
|
5
|
+
*
|
|
6
|
+
* Unlike `setColors` (a global singleton on the theme store, meant for the app's
|
|
7
|
+
* one brand palette), this is React context: it applies only to components
|
|
8
|
+
* rendered inside the provider, and unwinds automatically when that subtree
|
|
9
|
+
* unmounts. Use it for transient, scoped theming — a survey with custom brand
|
|
10
|
+
* colors, a preview pane, an embed — where a global swap would bleed into the
|
|
11
|
+
* rest of the app.
|
|
12
|
+
*/
|
|
13
|
+
const ThemeColorScopeContext = createContext(null);
|
|
14
|
+
/** Read the active scoped override (null when not inside a scope). */
|
|
15
|
+
export function useThemeColorScope() {
|
|
16
|
+
return useContext(ThemeColorScopeContext);
|
|
17
|
+
}
|
|
18
|
+
// Nested scopes layer: the inner scope's keys win, the outer scope's fill in.
|
|
19
|
+
function mergeScopes(parent, next) {
|
|
20
|
+
if (!parent)
|
|
21
|
+
return next;
|
|
22
|
+
return {
|
|
23
|
+
light: { ...parent.light, ...next.light },
|
|
24
|
+
dark: { ...parent.dark, ...next.dark },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function ThemeColorScope({ colors, children, }) {
|
|
28
|
+
const parent = useContext(ThemeColorScopeContext);
|
|
29
|
+
const value = useMemo(() => mergeScopes(parent, colors), [parent, colors]);
|
|
30
|
+
return (_jsx(ThemeColorScopeContext.Provider, { value: value, children: children }));
|
|
31
|
+
}
|