@thewhileloop/whileui 4.1.0 → 4.1.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/README.md CHANGED
@@ -11,6 +11,18 @@ production targets — iOS and Android set the interaction baseline. See
11
11
  [ROADMAP.md](./ROADMAP.md) for the product boundary and extraction rules, and
12
12
  [PLAN.md](./PLAN.md) for the supported stack and production contracts.
13
13
 
14
+ ## See it in action
15
+
16
+ One React Native component contract across native and web, with semantic typography,
17
+ token-driven light and dark themes, accessible controls, and application-owned chat
18
+ composition. The same component tree can feel airy and atmospheric or compact and
19
+ editorial by changing tokens alone.
20
+
21
+ <p align="center">
22
+ <img src="./docs/assets/whileui-showcase.gif" width="300" alt="WhileUI Cosmic Aura mobile theme demonstrating light and dark modes, controls, Select, and composable chat" />
23
+ <img src="./docs/assets/whileui-showcase-atelier.gif" width="300" alt="WhileUI Atelier mobile theme demonstrating compact controls, authentication forms, Select, and composable chat" />
24
+ </p>
25
+
14
26
  ## Installation
15
27
 
16
28
  ```bash
@@ -425,9 +437,9 @@ Copy from `apps/showcase/templates/profile/`:
425
437
 
426
438
  ### Media
427
439
 
428
- | Block | Description |
429
- | -------------- | ----------------------------------- |
430
- | **SmartImage** | Image with aspect ratio and loading |
440
+ | Block | Description |
441
+ | -------------- | ------------------------------------------------------------------ |
442
+ | **SmartImage** | Image with explicit loading, loaded, fallback, and error lifecycle |
431
443
 
432
444
  ### Date Picker
433
445
 
@@ -926,6 +938,9 @@ const adapter: ThemeBridgeAdapter = {
926
938
  const { mode, resolvedTheme, setMode, cycleMode } = useThemeBridge({ adapter });
927
939
  ```
928
940
 
941
+ The bridge loads a new adapter before saving into it. If a user changes the mode while
942
+ storage is still loading, that explicit choice wins over the late persisted value.
943
+
929
944
  When another store already owns the mode, mount the controlled bridge once near the
930
945
  application root. Change `mode` in the owning event handler; the bridge applies and
931
946
  persists it without mirroring state back through a callback effect.
@@ -1446,6 +1461,8 @@ import {
1446
1461
  Focused and pressed options use the subtle interaction-state tokens; the primary token
1447
1462
  marks the selected indicator without replacing the option's foreground colour. Long
1448
1463
  selected values stay on one line and truncate without displacing the trigger chevron.
1464
+ For popover presentation on web, the options panel matches the trigger width, caps
1465
+ itself to the collision-available viewport, and wraps long option labels inside it.
1449
1466
 
1450
1467
  `SelectContent` supports optional frosted props:
1451
1468
 
@@ -1507,7 +1524,7 @@ import {
1507
1524
  import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1508
1525
 
1509
1526
  <Avatar size="default">
1510
- <AvatarImage src="https://..." />
1527
+ <AvatarImage source={{ uri: 'https://...' }} />
1511
1528
  <AvatarFallback>JD</AvatarFallback>
1512
1529
  </Avatar>;
1513
1530
  ```
@@ -1516,6 +1533,10 @@ import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';
1516
1533
  | ---- | --------------------------- | ----------- |
1517
1534
  | size | `'sm' \| 'default' \| 'lg'` | `'default'` |
1518
1535
 
1536
+ `AvatarImage` keeps `AvatarFallback` visible while loading or after an error. Its
1537
+ `onLoadingStatusChange` callback reports `'loading'`, `'loaded'`, and `'error'`, including
1538
+ when a changed source begins loading, while the standard image event callbacks still run.
1539
+
1519
1540
  ## Progress
1520
1541
 
1521
1542
  ```tsx
@@ -1552,6 +1573,9 @@ toast({ title: 'Success', description: 'Saved!', variant: 'success' });
1552
1573
  | variant | `'default' \| 'success' \| 'destructive'` |
1553
1574
  | duration | `number` (ms) |
1554
1575
 
1576
+ Each toast owns its duration from the moment it appears; adding or dismissing another
1577
+ toast does not restart timers already in progress.
1578
+
1555
1579
  ## Popover
1556
1580
 
1557
1581
  ```tsx
@@ -1889,6 +1913,36 @@ code, and fenced code. It never opens links or writes to the clipboard itself:
1889
1913
  `onLinkPress` and `codeActions` keep those capabilities in the application. `CodeBlock`
1890
1914
  is dependency-free and accepts an application-owned `actions` slot.
1891
1915
 
1916
+ ## SmartImage
1917
+
1918
+ ```tsx
1919
+ import { SmartImage } from '@thewhileloop/whileui';
1920
+
1921
+ <SmartImage
1922
+ source={{ uri: imageUrl }}
1923
+ fallbackSource={{ uri: fallbackUrl }}
1924
+ alt="Product preview"
1925
+ className="h-48 w-full"
1926
+ />;
1927
+ ```
1928
+
1929
+ | Prop | Type | Default |
1930
+ | ------------------- | -------------------------------------------------- | --------- |
1931
+ | `source` | `ImageProps['source']` | required |
1932
+ | `alt` | `string` | required |
1933
+ | `fallbackSource` | `ImageProps['source']` | — |
1934
+ | `skeleton` | `boolean` | `true` |
1935
+ | `skeletonClassName` | `string` | — |
1936
+ | `transition` | `number` | `200` |
1937
+ | `radius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` |
1938
+ | `objectFit` | `'cover' \| 'contain' \| 'fill' \| 'scale-down'` | `'cover'` |
1939
+ | `onLoad` | `() => void` | — |
1940
+ | `onError` | `() => void` | — |
1941
+
1942
+ The skeleton returns whenever the primary image starts loading again. A failed primary
1943
+ source switches to `fallbackSource` when supplied without re-entering a primary/fallback
1944
+ loop.
1945
+
1892
1946
  ## DatePickerModal / DatePickerInline / DateRangePickerModal / TimePickerModal
1893
1947
 
1894
1948
  Date selection blocks using react-native-calendars. Theme-aware via `useCalendarTheme` (Uniwind light/dark). Optional `theme` prop for custom RN color strings (hex/rgb/hsl/named). `CalendarTheme` maps to react-native-calendars Theme and supports `arrowColor`, `disabledArrowColor`, and optional font keys.
@@ -2410,7 +2464,7 @@ Tracked work items for future releases.
2410
2464
  ### Documentation Gaps
2411
2465
 
2412
2466
  - [ ] Add `## API` sections for: Textarea, Toggle, ToggleGroup, Label, Separator, Skeleton, AspectRatio, Collapsible, Text, View
2413
- - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, SmartImage, TimelineFeed
2467
+ - [ ] Add `## API` sections for blocks: FloatingBottomNav, TabBar, FormModalScreen, ErrorState, LoadingScreen, ListItem, NotificationItem, MetricCard, TimelineFeed
2414
2468
 
2415
2469
  ### Infrastructure
2416
2470
 
@@ -44,21 +44,19 @@ function resolveRadius(visual, radius) {
44
44
  return visual.radiusMd;
45
45
  }
46
46
  const SmartImage = React.forwardRef(({ className, source, alt, skeleton = true, skeletonClassName, transition = 200, radius, objectFit, onLoad, onError, fallbackSource, style, ...props }, ref) => {
47
- const [loading, setLoading] = useState(true);
48
- const [error, setError] = useState(false);
47
+ const [phase, setPhase] = useState('loading');
49
48
  const visual = useVisualTokens();
50
49
  const borderRadius = resolveRadius(visual, radius ?? 'md');
51
50
  const handleLoad = () => {
52
- setLoading(false);
51
+ setPhase((current) => (current === 'fallback' ? 'fallback' : 'loaded'));
53
52
  onLoad?.();
54
53
  };
55
54
  const handleError = () => {
56
- setLoading(false);
57
- setError(true);
55
+ setPhase((current) => current === 'fallback' ? 'fallback' : fallbackSource ? 'fallback' : 'error');
58
56
  onError?.();
59
57
  };
60
- const imageSource = error && fallbackSource ? fallbackSource : source;
61
- return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && loading && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: loading && !error ? 0 : 1 } })] }));
58
+ const imageSource = phase === 'fallback' ? fallbackSource : source;
59
+ return (_jsxs(View, { ref: ref, className: cn(smartImageVariants({ radius, objectFit }), className), style: [{ borderRadius }, style], ...props, children: [skeleton && phase === 'loading' && (_jsx(Skeleton, { className: cn('absolute inset-0', skeletonClassName) })), _jsx(Image, { source: imageSource, alt: alt, transition: transition, onLoadStart: () => setPhase((current) => (current === 'fallback' ? current : 'loading')), onLoad: handleLoad, onError: handleError, className: cn('w-full h-full', objectFit === 'cover' && 'object-cover', objectFit === 'contain' && 'object-contain', objectFit === 'fill' && 'object-fill', objectFit === 'scale-down' && 'object-scale-down'), style: { opacity: phase === 'loading' ? 0 : 1 } })] }));
62
60
  });
63
61
  SmartImage.displayName = 'SmartImage';
64
62
  export { SmartImage, smartImageVariants };
@@ -27,15 +27,16 @@ declare const avatarVariants: import("tailwind-variants").TVReturnType<{
27
27
  export interface AvatarProps extends ViewProps, VariantProps<typeof avatarVariants> {
28
28
  className?: string;
29
29
  }
30
+ export type AvatarImageStatus = 'loading' | 'loaded' | 'error';
30
31
  export interface AvatarImageProps extends Omit<ImageProps, 'style'> {
31
32
  className?: string;
32
- onLoadingStatusChange?: (status: 'loading' | 'loaded' | 'error') => void;
33
+ onLoadingStatusChange?: (status: AvatarImageStatus) => void;
33
34
  }
34
35
  export interface AvatarFallbackProps extends ViewProps {
35
36
  className?: string;
36
37
  }
37
38
  declare const Avatar: ForwardRefComponent<ViewInstance, AvatarProps>;
38
- declare function AvatarImage({ className, onLoadingStatusChange, ...props }: AvatarImageProps): React.JSX.Element;
39
+ declare function AvatarImage({ className, onLoadingStatusChange, onLoadStart, onLoad, onError, ...props }: AvatarImageProps): React.JSX.Element;
39
40
  declare namespace AvatarImage {
40
41
  var displayName: string;
41
42
  }
@@ -20,30 +20,36 @@ const avatarVariants = tv({
20
20
  },
21
21
  });
22
22
  const AvatarContext = React.createContext({
23
- imageLoaded: false,
24
- setImageLoaded: () => { },
23
+ imageStatus: 'loading',
24
+ setImageStatus: () => { },
25
25
  });
26
26
  const Avatar = React.forwardRef(({ className, size, ...props }, ref) => {
27
- const [imageLoaded, setImageLoaded] = useState(false);
28
- const contextValue = useMemo(() => ({ imageLoaded, setImageLoaded }), [imageLoaded]);
27
+ const [imageStatus, setImageStatus] = useState('loading');
28
+ const contextValue = useMemo(() => ({ imageStatus, setImageStatus }), [imageStatus]);
29
29
  return (_jsx(AvatarContext.Provider, { value: contextValue, children: _jsx(View, { ref: ref, className: cn(avatarVariants({ size }), className), ...props }) }));
30
30
  });
31
31
  Avatar.displayName = 'Avatar';
32
- function AvatarImage({ className, onLoadingStatusChange, ...props }) {
33
- const { setImageLoaded } = React.useContext(AvatarContext);
34
- return (_jsx(Image, { className: cn('aspect-square h-full w-full rounded-full', className), onLoad: () => {
35
- setImageLoaded(true);
32
+ function AvatarImage({ className, onLoadingStatusChange, onLoadStart, onLoad, onError, ...props }) {
33
+ const { setImageStatus } = React.useContext(AvatarContext);
34
+ return (_jsx(Image, { ...props, className: cn('aspect-square h-full w-full rounded-full', className), onLoadStart: () => {
35
+ setImageStatus('loading');
36
+ onLoadingStatusChange?.('loading');
37
+ onLoadStart?.();
38
+ }, onLoad: (event) => {
39
+ setImageStatus('loaded');
36
40
  onLoadingStatusChange?.('loaded');
37
- }, onError: () => {
38
- setImageLoaded(false);
41
+ onLoad?.(event);
42
+ }, onError: (event) => {
43
+ setImageStatus('error');
39
44
  onLoadingStatusChange?.('error');
40
- }, ...props }));
45
+ onError?.(event);
46
+ } }));
41
47
  }
42
48
  AvatarImage.displayName = 'AvatarImage';
43
49
  function AvatarFallback({ className, children, ...props }) {
44
- const { imageLoaded } = React.useContext(AvatarContext);
50
+ const { imageStatus } = React.useContext(AvatarContext);
45
51
  const visual = useVisualTokens();
46
- if (imageLoaded)
52
+ if (imageStatus === 'loaded')
47
53
  return null;
48
54
  return (_jsx(View, { className: cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className), ...props, children: typeof children === 'string' ? (_jsx(Text, { className: "font-medium text-muted-foreground", style: typographyStyle(visual, 'label'), children: children })) : (children) }));
49
55
  }
@@ -1 +1 @@
1
- export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarFallbackProps, } from './avatar';
1
+ export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarImageStatus, type AvatarFallbackProps, } from './avatar';
@@ -59,21 +59,33 @@ const SelectContent = React.forwardRef(({ className, children, portalHost, posit
59
59
  });
60
60
  const visual = useVisualTokens();
61
61
  const colors = useThemeColors();
62
- return (_jsx(SelectPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsxs(SelectPrimitive.Content, { ref: ref, position: position, insets: insets, style: StyleSheet.flatten([
63
- {
64
- borderRadius: surfaceRadius(visual, 'lg'),
65
- borderWidth: visual.borderWidthHairline,
66
- },
67
- shadowStyle(visual, colors, 'md'),
68
- frostedSurface.surfaceStyle,
69
- styleProp,
70
- ]), className: cn('border-border relative z-50 min-w-32 overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', Platform.select({
71
- web: cn('max-h-96 overflow-y-auto overflow-x-hidden', props.side === 'bottom' && 'translate-y-1', props.side === 'top' && '-translate-y-1'),
62
+ // Radix applies its collision variables to the listbox wrapper, while
63
+ // @rn-primitives renders our visual surface one level inside it. Keep the sizing
64
+ // on that surface as inline web CSS: the Viewport's own `width: 100%` otherwise
65
+ // wins class merging and lets the longest option size the outer popper.
66
+ const webSizingStyle = Platform.OS === 'web' && position === 'popper'
67
+ ? {
68
+ boxSizing: 'border-box',
69
+ width: 'var(--radix-select-trigger-width)',
70
+ maxWidth: 'var(--radix-select-content-available-width)',
71
+ maxHeight: 'var(--radix-select-content-available-height)',
72
+ }
73
+ : undefined;
74
+ return (_jsx(SelectPrimitive.Portal, { hostName: portalHost, children: _jsx(FullWindowOverlay, { children: _jsx(SelectPrimitive.Overlay, { style: Platform.select({ native: StyleSheet.absoluteFill }), children: _jsxs(SelectPrimitive.Content, { ref: ref, position: position, insets: insets, style: {
75
+ ...webSizingStyle,
76
+ ...StyleSheet.flatten([
77
+ {
78
+ borderRadius: surfaceRadius(visual, 'lg'),
79
+ borderWidth: visual.borderWidthHairline,
80
+ },
81
+ shadowStyle(visual, colors, 'md'),
82
+ frostedSurface.surfaceStyle,
83
+ styleProp,
84
+ ]),
85
+ }, className: cn('border-border relative z-50 overflow-hidden', frosted ? 'bg-transparent' : 'bg-popover', Platform.select({
86
+ web: cn('overflow-y-auto overflow-x-hidden', props.side === 'bottom' && 'translate-y-1', props.side === 'top' && '-translate-y-1'),
72
87
  native: 'p-1',
73
- }), className), ...props, children: [frostedSurface.overlay, _jsx(SelectPrimitive.Viewport, { className: cn('p-1', position === 'popper' &&
74
- Platform.select({
75
- web: 'h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)',
76
- })), children: children })] }) }) }) }));
88
+ }), className), ...props, children: [frostedSurface.overlay, _jsx(SelectPrimitive.Viewport, { className: "p-1", children: children })] }) }) }) }));
77
89
  });
78
90
  SelectContent.displayName = 'SelectContent';
79
91
  const SelectItem = React.forwardRef(({ className, children, style: styleProp, ...props }, ref) => {
@@ -90,7 +102,7 @@ const SelectItem = React.forwardRef(({ className, children, style: styleProp, ..
90
102
  },
91
103
  styleProp,
92
104
  props.disabled ? { opacity: interaction.disabledOpacity } : null,
93
- ]), ...props, children: [_jsx(View, { className: "absolute right-3 flex h-4 w-4 items-center justify-center", children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(View, { className: "border-primary h-2.5 w-1.5 rotate-45 border-b-2 border-r-2 -mt-0.5" }) }) }), children, _jsx(SelectPrimitive.ItemText, { className: "text-foreground", style: typographyStyle(visual, 'body') })] }));
105
+ ]), ...props, children: [_jsx(View, { className: "absolute right-3 flex h-4 w-4 items-center justify-center", children: _jsx(SelectPrimitive.ItemIndicator, { children: _jsx(View, { className: "border-primary h-2.5 w-1.5 rotate-45 border-b-2 border-r-2 -mt-0.5" }) }) }), children, _jsx(SelectPrimitive.ItemText, { className: "min-w-0 flex-1 text-foreground web:whitespace-normal web:break-words", style: typographyStyle(visual, 'body') })] }));
94
106
  });
95
107
  SelectItem.displayName = 'SelectItem';
96
108
  function SelectItemIcon({ className, children }) {
@@ -86,10 +86,14 @@ function Toast({ toast: toastData, onDismiss, className, style, ...props }) {
86
86
  paddingVertical: visual.controlPaddingYSm,
87
87
  }, children: _jsx(Text, { className: cn('font-medium', textColor), style: typographyStyle(visual, 'label'), children: action.label }) })), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Dismiss", onPress: onDismiss, className: "p-1", children: _jsx(Text, { className: cn(textColor), style: typographyStyle(visual, 'emphasis'), children: "\u00D7" }) })] })] }));
88
88
  }
89
+ function ToastContainerItem({ toast, dismiss, }) {
90
+ const handleDismiss = useCallback(() => dismiss(toast.id), [dismiss, toast.id]);
91
+ return (_jsx(View, { className: "mb-2", children: _jsx(Toast, { toast: toast, onDismiss: handleDismiss }) }));
92
+ }
89
93
  export function ToastContainer({ position = 'top', className, style, ...props }) {
90
94
  const { toasts, dismiss } = useToast();
91
95
  const visual = useVisualTokens();
92
96
  const positionClass = position === 'top' ? 'top-0' : 'bottom-0';
93
- return (_jsx(View, { className: cn('absolute left-0 right-0 z-50 py-2', positionClass, className), style: [{ paddingHorizontal: visual.surfacePaddingSm }, style], pointerEvents: "box-none", ...props, children: toasts.map((toast) => (_jsx(View, { className: "mb-2", children: _jsx(Toast, { toast: toast, onDismiss: () => dismiss(toast.id) }) }, toast.id))) }));
97
+ return (_jsx(View, { className: cn('absolute left-0 right-0 z-50 py-2', positionClass, className), style: [{ paddingHorizontal: visual.surfacePaddingSm }, style], pointerEvents: "box-none", ...props, children: toasts.map((toast) => (_jsx(ToastContainerItem, { toast: toast, dismiss: dismiss }, toast.id))) }));
94
98
  }
95
99
  export { Toast, toastVariants };
package/dist/index.d.ts CHANGED
@@ -40,7 +40,7 @@ export { Spinner, spinnerVariants, type SpinnerProps } from './components/spinne
40
40
  export { Textarea, textareaVariants, type TextareaProps } from './components/textarea';
41
41
  export { Skeleton, skeletonVariants, type SkeletonProps } from './components/skeleton';
42
42
  export { AspectRatio, type AspectRatioProps } from './components/aspect-ratio';
43
- export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarFallbackProps, } from './components/avatar';
43
+ export { Avatar, AvatarImage, AvatarFallback, avatarVariants, type AvatarProps, type AvatarImageProps, type AvatarImageStatus, type AvatarFallbackProps, } from './components/avatar';
44
44
  export { Accordion, AccordionItem, AccordionTrigger, AccordionContent, type AccordionProps, type AccordionItemProps, type AccordionTriggerProps, type AccordionContentProps, } from './components/accordion';
45
45
  export { Collapsible, CollapsibleTrigger, CollapsibleContent, type CollapsibleProps, type CollapsibleTriggerProps, type CollapsibleContentProps, } from './components/collapsible';
46
46
  export { Container, containerVariants, type ContainerProps } from './components/container';
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thewhileloop/whileui",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "WhileUI Native — Copy-paste components for React Native. You own the code.",
5
5
  "license": "MIT",
6
6
  "repository": {