@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 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();
@@ -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 any app-injected palette over the package defaults for the active
55
- // scheme. When no override is present we return the base theme *by reference*
56
- // so memoization and identity checks downstream stay stable (and the package
57
- // behaves exactly as it did before overrides existed).
58
- const override = colorOverrides[effectiveScheme];
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
- if (!override || Object.keys(override).length === 0) {
61
- return base;
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, ...override },
72
+ colors: { ...base.colors, ...storeOverride, ...scopedOverride },
66
73
  };
67
- }, [base, override]);
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") {
@@ -1,3 +1,4 @@
1
1
  export * from "./globalUIStore";
2
2
  export * from "./themeStore";
3
+ export * from "./themeColorScope";
3
4
  export * from "./SsrViewportContext";
@@ -1,3 +1,4 @@
1
1
  export * from "./globalUIStore.js";
2
2
  export * from "./themeStore.js";
3
+ export * from "./themeColorScope.js";
3
4
  export * from "./SsrViewportContext.js";
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrmeg/expo-ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "description": "Reusable Expo and React Native UI primitives for MrMeg projects.",
6
6
  "keywords": [