@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 +59 -5
- package/dist/blocks/media/smart-image.js +5 -7
- package/dist/components/avatar/avatar.d.ts +3 -2
- package/dist/components/avatar/avatar.js +19 -13
- package/dist/components/avatar/index.d.ts +1 -1
- package/dist/components/select/select.js +27 -15
- package/dist/components/toast/toast.js +5 -1
- package/dist/index.d.ts +1 -1
- package/dist/lib/theme-bridge.d.ts +1 -1
- package/dist/lib/theme-bridge.js +52 -18
- package/package.json +1 -1
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
|
|
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
|
|
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,
|
|
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 [
|
|
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
|
-
|
|
51
|
+
setPhase((current) => (current === 'fallback' ? 'fallback' : 'loaded'));
|
|
53
52
|
onLoad?.();
|
|
54
53
|
};
|
|
55
54
|
const handleError = () => {
|
|
56
|
-
|
|
57
|
-
setError(true);
|
|
55
|
+
setPhase((current) => current === 'fallback' ? 'fallback' : fallbackSource ? 'fallback' : 'error');
|
|
58
56
|
onError?.();
|
|
59
57
|
};
|
|
60
|
-
const imageSource =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
24
|
-
|
|
23
|
+
imageStatus: 'loading',
|
|
24
|
+
setImageStatus: () => { },
|
|
25
25
|
});
|
|
26
26
|
const Avatar = React.forwardRef(({ className, size, ...props }, ref) => {
|
|
27
|
-
const [
|
|
28
|
-
const contextValue = useMemo(() => ({
|
|
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 {
|
|
34
|
-
return (_jsx(Image, { className: cn('aspect-square h-full w-full rounded-full', className),
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
41
|
+
onLoad?.(event);
|
|
42
|
+
}, onError: (event) => {
|
|
43
|
+
setImageStatus('error');
|
|
39
44
|
onLoadingStatusChange?.('error');
|
|
40
|
-
|
|
45
|
+
onError?.(event);
|
|
46
|
+
} }));
|
|
41
47
|
}
|
|
42
48
|
AvatarImage.displayName = 'AvatarImage';
|
|
43
49
|
function AvatarFallback({ className, children, ...props }) {
|
|
44
|
-
const {
|
|
50
|
+
const { imageStatus } = React.useContext(AvatarContext);
|
|
45
51
|
const visual = useVisualTokens();
|
|
46
|
-
if (
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
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(
|
|
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, "
|
|
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
|
}>;
|
package/dist/lib/theme-bridge.js
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import { createElement, useCallback, useEffect, useMemo,
|
|
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 [
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
63
|
-
const
|
|
64
|
-
|
|
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(() => {
|