@thewhileloop/whileui 4.1.0 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +120 -22
  2. package/dist/blocks/datepicker/date-range-picker-modal.js +3 -2
  3. package/dist/blocks/datepicker/datepicker-modal.js +3 -2
  4. package/dist/blocks/datepicker/time-picker-modal.js +3 -2
  5. package/dist/blocks/layout/confirm-action-sheet.js +3 -2
  6. package/dist/blocks/layout/index.d.ts +1 -0
  7. package/dist/blocks/layout/index.js +1 -0
  8. package/dist/blocks/layout/loading-state.d.ts +8 -0
  9. package/dist/blocks/layout/loading-state.js +12 -0
  10. package/dist/blocks/layout/sheet.js +21 -2
  11. package/dist/blocks/media/smart-image.js +5 -7
  12. package/dist/blocks/navigation/drawer-menu.js +10 -55
  13. package/dist/blocks/navigation/header.d.ts +2 -0
  14. package/dist/blocks/navigation/header.js +1 -1
  15. package/dist/components/alert/alert.js +4 -1
  16. package/dist/components/alert-dialog/alert-dialog.js +3 -2
  17. package/dist/components/avatar/avatar.d.ts +3 -2
  18. package/dist/components/avatar/avatar.js +19 -13
  19. package/dist/components/avatar/index.d.ts +1 -1
  20. package/dist/components/context-menu/context-menu.js +4 -3
  21. package/dist/components/dialog/dialog.js +4 -3
  22. package/dist/components/dropdown-menu/dropdown-menu.js +4 -3
  23. package/dist/components/hover-card/hover-card.js +5 -1
  24. package/dist/components/menubar/menubar.js +5 -4
  25. package/dist/components/popover/popover.js +5 -1
  26. package/dist/components/select/select.js +36 -15
  27. package/dist/components/text/text.d.ts +3 -0
  28. package/dist/components/text/text.js +2 -0
  29. package/dist/components/toast/toast.js +5 -1
  30. package/dist/components/toggle/toggle.d.ts +1 -1
  31. package/dist/components/toggle/toggle.js +1 -1
  32. package/dist/components/tooltip/tooltip.js +3 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/lib/overlay-host.d.ts +26 -0
  35. package/dist/lib/overlay-host.js +77 -0
  36. package/dist/lib/overlay-presence.d.ts +15 -0
  37. package/dist/lib/overlay-presence.js +55 -0
  38. package/dist/lib/recipes.d.ts +1 -1
  39. package/dist/lib/recipes.js +24 -5
  40. package/dist/lib/theme-bridge.d.ts +1 -1
  41. package/dist/lib/theme-bridge.js +52 -18
  42. package/dist/metro.cjs +33 -0
  43. package/dist/metro.d.cts +21 -0
  44. package/package.json +6 -2
@@ -0,0 +1,15 @@
1
+ interface OverlayPresenceOptions {
2
+ visible: boolean;
3
+ openDuration: number;
4
+ closeDuration: number;
5
+ }
6
+ /**
7
+ * Keeps an overlay mounted through its exit animation. `progress` runs 0 → 1 on open and
8
+ * back to 0 on close; `mounted` drops once the exit finishes. Reduced motion and the
9
+ * disabled test policy collapse both durations to zero.
10
+ */
11
+ export declare function useOverlayPresence({ visible, openDuration, closeDuration, }: OverlayPresenceOptions): {
12
+ mounted: boolean;
13
+ progress: import("react-native-reanimated").SharedValue<number>;
14
+ };
15
+ export {};
@@ -0,0 +1,55 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Easing, useSharedValue, withTiming } from 'react-native-reanimated';
3
+ import { useMotionPolicy } from './motion-policy';
4
+ /**
5
+ * Keeps an overlay mounted through its exit animation. `progress` runs 0 → 1 on open and
6
+ * back to 0 on close; `mounted` drops once the exit finishes. Reduced motion and the
7
+ * disabled test policy collapse both durations to zero.
8
+ */
9
+ export function useOverlayPresence({ visible, openDuration, closeDuration, }) {
10
+ const motion = useMotionPolicy();
11
+ const progress = useSharedValue(0);
12
+ const [mounted, setMounted] = useState(visible);
13
+ const closeTimerRef = useRef(null);
14
+ const [prevVisible, setPrevVisible] = useState(visible);
15
+ // Open in the prop-change render so the first animation frame has a mounted panel;
16
+ // closing stays mounted on the timer until the exit animation finishes.
17
+ if (prevVisible !== visible) {
18
+ setPrevVisible(visible);
19
+ if (visible)
20
+ setMounted(true);
21
+ }
22
+ useEffect(() => {
23
+ if (!mounted)
24
+ return;
25
+ if (closeTimerRef.current) {
26
+ clearTimeout(closeTimerRef.current);
27
+ closeTimerRef.current = null;
28
+ }
29
+ const duration = motion.reduceMotion || !motion.animationsEnabled ? 0 : visible ? openDuration : closeDuration;
30
+ progress.value = withTiming(visible ? 1 : 0, {
31
+ duration,
32
+ reduceMotion: motion.reanimatedReduceMotion,
33
+ easing: visible ? Easing.bezier(0.2, 0.8, 0.2, 1) : Easing.bezier(0.4, 0, 1, 1),
34
+ });
35
+ if (!visible) {
36
+ closeTimerRef.current = setTimeout(() => setMounted(false), duration);
37
+ }
38
+ return () => {
39
+ if (closeTimerRef.current) {
40
+ clearTimeout(closeTimerRef.current);
41
+ closeTimerRef.current = null;
42
+ }
43
+ };
44
+ }, [
45
+ mounted,
46
+ visible,
47
+ progress,
48
+ openDuration,
49
+ closeDuration,
50
+ motion.animationsEnabled,
51
+ motion.reduceMotion,
52
+ motion.reanimatedReduceMotion,
53
+ ]);
54
+ return { mounted, progress };
55
+ }
@@ -1,4 +1,4 @@
1
- import type { TextStyle, ViewStyle } from 'react-native';
1
+ import { type TextStyle, type ViewStyle } from 'react-native';
2
2
  import { type ThemeColors } from './theme-colors';
3
3
  import { type VisualTokens } from './visual-tokens';
4
4
  /**
@@ -1,3 +1,5 @@
1
+ import { Platform } from 'react-native';
2
+ import { formatRgb, parse } from 'culori';
1
3
  import { useThemeColors } from './theme-colors';
2
4
  import { useVisualTokens } from './visual-tokens';
3
5
  export function controlRecipe(visual, size = 'default') {
@@ -92,14 +94,31 @@ export function shadowStyle(visual, colors, tier) {
92
94
  : visual.shadowElevationLg;
93
95
  if (opacity <= 0)
94
96
  return {};
97
+ // Android below 9 cannot draw an outset box shadow and keeps the elevation fallback.
98
+ if (Platform.OS === 'android' && typeof Platform.Version === 'number' && Platform.Version < 28) {
99
+ return { elevation };
100
+ }
101
+ // A box shadow is clipped outside the border box, so it never shows through a
102
+ // translucent surface. Elevation and the legacy `shadow*` props draw under the whole
103
+ // view, which greyed every translucent card on Android. CSS blur is twice the
104
+ // Core Animation radius the tokens were tuned for.
95
105
  return {
96
- shadowColor: colors.shadow,
97
- shadowOpacity: opacity,
98
- shadowRadius: radius,
99
- shadowOffset: { width: 0, height: offsetY },
100
- elevation,
106
+ boxShadow: [
107
+ {
108
+ offsetX: 0,
109
+ offsetY,
110
+ blurRadius: radius * 2,
111
+ color: shadowColorWithOpacity(colors.shadow, opacity),
112
+ },
113
+ ],
101
114
  };
102
115
  }
116
+ function shadowColorWithOpacity(color, opacity) {
117
+ const parsed = parse(color);
118
+ if (!parsed)
119
+ return color;
120
+ return formatRgb({ ...parsed, alpha: opacity });
121
+ }
103
122
  // Component-owned text sizes. Font family/weight stays on className
104
123
  // (font-medium etc.) so app font mappings keep working.
105
124
  export function typographyStyle(visual, role) {
@@ -23,7 +23,7 @@ export declare function applyThemeMode(mode: ThemeMode): void;
23
23
  export declare function resolveThemeMode(adapter?: ThemeBridgeAdapter, fallback?: ThemeMode): Promise<ThemeMode>;
24
24
  export declare function syncThemeMode(mode: ThemeMode, adapter?: ThemeBridgeAdapter): Promise<ThemeMode>;
25
25
  export declare function useThemeBridge({ initialMode, adapter, }?: UseThemeBridgeOptions): UseThemeBridgeResult;
26
- export declare function ThemeBridge({ mode, initialMode, adapter }: ThemeBridgeProps): import("react").FunctionComponentElement<Pick<ThemeBridgeProps, "initialMode" | "adapter">> | import("react").FunctionComponentElement<{
26
+ export declare function ThemeBridge({ mode, initialMode, adapter }: ThemeBridgeProps): import("react").FunctionComponentElement<Pick<ThemeBridgeProps, "adapter" | "initialMode">> | import("react").FunctionComponentElement<{
27
27
  mode: ThemeMode;
28
28
  adapter?: ThemeBridgeAdapter;
29
29
  }>;
@@ -1,6 +1,37 @@
1
- import { createElement, useCallback, useEffect, useMemo, useState } from 'react';
1
+ import { createElement, useCallback, useEffect, useMemo, useReducer } from 'react';
2
2
  import { Uniwind, useUniwind } from 'uniwind';
3
3
  const modeCycle = ['light', 'dark', 'system'];
4
+ function themeBridgeReducer(state, action) {
5
+ if (action.type === 'cycle') {
6
+ return {
7
+ ...state,
8
+ status: 'ready',
9
+ mode: modeCycle[(modeCycle.indexOf(state.mode) + 1) % modeCycle.length],
10
+ };
11
+ }
12
+ if (action.type === 'select') {
13
+ return { ...state, status: 'ready', mode: action.mode };
14
+ }
15
+ if (action.type === 'hydrate') {
16
+ if (state.adapter === action.adapter &&
17
+ state.initialMode === action.initialMode &&
18
+ state.status === (action.adapter ? 'hydrating' : 'ready')) {
19
+ return state;
20
+ }
21
+ return {
22
+ status: action.adapter ? 'hydrating' : 'ready',
23
+ mode: state.mode,
24
+ adapter: action.adapter,
25
+ initialMode: action.initialMode,
26
+ };
27
+ }
28
+ if (state.status !== 'hydrating' ||
29
+ state.adapter !== action.adapter ||
30
+ state.initialMode !== action.initialMode) {
31
+ return state;
32
+ }
33
+ return { ...state, status: 'ready', mode: action.mode };
34
+ }
4
35
  export function applyThemeMode(mode) {
5
36
  Uniwind.setTheme(mode);
6
37
  }
@@ -23,16 +54,21 @@ export async function syncThemeMode(mode, adapter) {
23
54
  }
24
55
  export function useThemeBridge({ initialMode = 'system', adapter, } = {}) {
25
56
  const { theme } = useUniwind();
26
- const [mode, setMode] = useState(initialMode);
27
- const [hydrated, setHydrated] = useState(!adapter);
57
+ const [state, dispatch] = useReducer(themeBridgeReducer, {
58
+ status: adapter ? 'hydrating' : 'ready',
59
+ mode: initialMode,
60
+ adapter,
61
+ initialMode,
62
+ });
28
63
  useEffect(() => {
29
64
  let mounted = true;
65
+ dispatch({ type: 'hydrate', adapter, initialMode });
30
66
  if (!adapter) {
31
67
  return;
32
68
  }
33
69
  // `loadThemeMode` is the consumer's storage. If it rejects — a corrupt store, a
34
70
  // keychain prompt the user dismissed — the app must still render in the initial
35
- // mode; leaving `hydrated` false would strand anything gated on it forever.
71
+ // mode rather than remaining in the hydrating phase forever.
36
72
  void (async () => {
37
73
  let next;
38
74
  try {
@@ -44,35 +80,33 @@ export function useThemeBridge({ initialMode = 'system', adapter, } = {}) {
44
80
  if (!mounted) {
45
81
  return;
46
82
  }
47
- setMode(next);
48
- setHydrated(true);
83
+ dispatch({ type: 'hydrated', adapter, initialMode, mode: next });
49
84
  })();
50
85
  return () => {
51
86
  mounted = false;
52
87
  };
53
88
  }, [adapter, initialMode]);
54
89
  useEffect(() => {
55
- if (!hydrated) {
90
+ if (state.status !== 'ready' ||
91
+ state.adapter !== adapter ||
92
+ state.initialMode !== initialMode) {
56
93
  return;
57
94
  }
58
- applyThemeMode(mode);
95
+ applyThemeMode(state.mode);
59
96
  if (adapter) {
60
- void adapter.saveThemeMode(mode);
97
+ void adapter.saveThemeMode(state.mode);
61
98
  }
62
- }, [adapter, hydrated, mode]);
63
- const cycleMode = useCallback(() => {
64
- setMode((currentMode) => {
65
- const currentIndex = modeCycle.indexOf(currentMode);
66
- const nextIndex = (currentIndex + 1) % modeCycle.length;
67
- return modeCycle[nextIndex];
68
- });
99
+ }, [adapter, initialMode, state]);
100
+ const setMode = useCallback((mode) => {
101
+ dispatch({ type: 'select', mode });
69
102
  }, []);
103
+ const cycleMode = useCallback(() => dispatch({ type: 'cycle' }), []);
70
104
  return useMemo(() => ({
71
- mode,
105
+ mode: state.mode,
72
106
  resolvedTheme: theme,
73
107
  setMode,
74
108
  cycleMode,
75
- }), [cycleMode, mode, theme]);
109
+ }), [cycleMode, setMode, state.mode, theme]);
76
110
  }
77
111
  function ControlledThemeBridge({ mode, adapter, }) {
78
112
  useEffect(() => {
package/dist/metro.cjs ADDED
@@ -0,0 +1,33 @@
1
+ 'use strict';
2
+
3
+ const { withUniwindConfig } = require('uniwind/metro');
4
+
5
+ /**
6
+ * Packages that must resolve the real `react-native`. Uniwind's native resolver
7
+ * rewrites every `from 'react-native'` to its component wrapper, which re-exports
8
+ * components and nothing else, so a package importing a non-component export gets
9
+ * `undefined` with no build error. `expo-asset` imports `AssetRegistry` that way, and
10
+ * without it every `@expo/vector-icons` glyph is silently absent on iOS and Android.
11
+ */
12
+ const NEEDS_REAL_REACT_NATIVE = ['expo-asset', 'expo-font', '@expo/vector-icons'];
13
+
14
+ function withWhileUIMetro(config, options) {
15
+ const { exemptFromUniwindRewrite = [], ...uniwindOptions } = options;
16
+ const wrapped = withUniwindConfig(config, uniwindOptions);
17
+ const exempt = [...NEEDS_REAL_REACT_NATIVE, ...exemptFromUniwindRewrite];
18
+ const uniwindResolveRequest = wrapped.resolver.resolveRequest;
19
+
20
+ wrapped.resolver.resolveRequest = (context, moduleName, platform) => {
21
+ if (moduleName === 'react-native') {
22
+ const origin = context.originModulePath ?? '';
23
+ if (exempt.some((pkg) => origin.includes(`node_modules/${pkg}/`))) {
24
+ return context.resolveRequest(context, moduleName, platform);
25
+ }
26
+ }
27
+ return uniwindResolveRequest(context, moduleName, platform);
28
+ };
29
+
30
+ return wrapped;
31
+ }
32
+
33
+ module.exports = { withWhileUIMetro, NEEDS_REAL_REACT_NATIVE };
@@ -0,0 +1,21 @@
1
+ import type { withUniwindConfig } from 'uniwind/metro';
2
+
3
+ type UniwindMetroOptions = Parameters<typeof withUniwindConfig>[1];
4
+
5
+ export interface WithWhileUIMetroOptions extends UniwindMetroOptions {
6
+ /** Extra packages that must import the real `react-native` instead of Uniwind's wrapper. */
7
+ exemptFromUniwindRewrite?: string[];
8
+ }
9
+
10
+ /** Packages exempted from Uniwind's `react-native` rewrite by default. */
11
+ export const NEEDS_REAL_REACT_NATIVE: readonly string[];
12
+
13
+ /**
14
+ * Wraps an Expo Metro config with Uniwind and keeps `expo-asset`, `expo-font` and
15
+ * `@expo/vector-icons` on the real `react-native`, so icon fonts load on device.
16
+ * Use this in `metro.config.js` instead of calling `withUniwindConfig` yourself.
17
+ */
18
+ export function withWhileUIMetro<T extends Parameters<typeof withUniwindConfig>[0]>(
19
+ config: T,
20
+ options: WithWhileUIMetroOptions
21
+ ): ReturnType<typeof withUniwindConfig>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/vite.d.ts",
37
37
  "import": "./dist/vite.js"
38
38
  },
39
+ "./metro": {
40
+ "types": "./dist/metro.d.cts",
41
+ "default": "./dist/metro.cjs"
42
+ },
39
43
  "./tokens": {
40
44
  "types": "./dist/tokens.d.ts",
41
45
  "import": "./dist/tokens.js"
@@ -59,7 +63,7 @@
59
63
  "access": "public"
60
64
  },
61
65
  "scripts": {
62
- "build": "tsc -p tsconfig.build.json",
66
+ "build": "tsc -p tsconfig.build.json && cp src/metro.cjs src/metro.d.cts dist/",
63
67
  "prepack": "cp ../../README.md README.md",
64
68
  "postpack": "rm -f README.md",
65
69
  "prepare": "bun run build",