panelui-native 0.5.3 → 0.6.0
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 +8 -4
- package/lib/module/components/scroll-fade/index.js +156 -0
- package/lib/module/components/scroll-fade/index.js.map +1 -0
- package/lib/module/components/shimmer/index.js +120 -0
- package/lib/module/components/shimmer/index.js.map +1 -0
- package/lib/module/components/surface/index.js +64 -0
- package/lib/module/components/surface/index.js.map +1 -0
- package/lib/module/hooks/index.js +9 -0
- package/lib/module/hooks/index.js.map +1 -0
- package/lib/module/hooks/use-breakpoint.js +39 -0
- package/lib/module/hooks/use-breakpoint.js.map +1 -0
- package/lib/module/hooks/use-copy-to-clipboard.js +62 -0
- package/lib/module/hooks/use-copy-to-clipboard.js.map +1 -0
- package/lib/module/hooks/use-debounced-value.js +24 -0
- package/lib/module/hooks/use-debounced-value.js.map +1 -0
- package/lib/module/hooks/use-disclosure.js +43 -0
- package/lib/module/hooks/use-disclosure.js.map +1 -0
- package/lib/module/hooks/use-keyboard.js +36 -0
- package/lib/module/hooks/use-keyboard.js.map +1 -0
- package/lib/module/hooks/use-previous.js +25 -0
- package/lib/module/hooks/use-previous.js.map +1 -0
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/scroll-fade/index.d.ts +38 -0
- package/lib/typescript/src/components/scroll-fade/index.d.ts.map +1 -0
- package/lib/typescript/src/components/shimmer/index.d.ts +42 -0
- package/lib/typescript/src/components/shimmer/index.d.ts.map +1 -0
- package/lib/typescript/src/components/surface/index.d.ts +30 -0
- package/lib/typescript/src/components/surface/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +7 -0
- package/lib/typescript/src/hooks/index.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +32 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts +30 -0
- package/lib/typescript/src/hooks/use-copy-to-clipboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts +13 -0
- package/lib/typescript/src/hooks/use-debounced-value.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts +32 -0
- package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts +20 -0
- package/lib/typescript/src/hooks/use-keyboard.d.ts.map +1 -0
- package/lib/typescript/src/hooks/use-previous.d.ts +15 -0
- package/lib/typescript/src/hooks/use-previous.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +8 -1
- package/src/components/scroll-fade/index.tsx +168 -0
- package/src/components/shimmer/index.tsx +135 -0
- package/src/components/surface/index.tsx +63 -0
- package/src/hooks/index.ts +19 -0
- package/src/hooks/use-breakpoint.ts +52 -0
- package/src/hooks/use-copy-to-clipboard.ts +81 -0
- package/src/hooks/use-debounced-value.ts +23 -0
- package/src/hooks/use-disclosure.ts +57 -0
- package/src/hooks/use-keyboard.ts +43 -0
- package/src/hooks/use-previous.ts +24 -0
- package/src/index.ts +9 -0
- package/theme.css +18 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shimmer — an animated highlight sweeping across content.
|
|
3
|
+
*
|
|
4
|
+
* The React Native equivalent of shadcn-ui/ui's `shimmer` utility
|
|
5
|
+
* (https://ui.shadcn.com/docs/utils/shimmer). That one is CSS:
|
|
6
|
+
* `background-clip: text` with a `color-mix()` highlight. React Native has
|
|
7
|
+
* neither, so the highlight is drawn as an SVG gradient band swept across an
|
|
8
|
+
* overlay by Reanimated, on the UI thread.
|
|
9
|
+
*
|
|
10
|
+
* The visible difference: the band passes *over* the content rather than
|
|
11
|
+
* being clipped to glyph shapes. On body-sized text the two read the same; on
|
|
12
|
+
* very large text the web version is crisper.
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <Shimmer>
|
|
16
|
+
* <Text muted>Generating response…</Text>
|
|
17
|
+
* </Shimmer>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
21
|
+
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
22
|
+
import Animated, {
|
|
23
|
+
cancelAnimation,
|
|
24
|
+
Easing,
|
|
25
|
+
useAnimatedStyle,
|
|
26
|
+
useReducedMotion,
|
|
27
|
+
useSharedValue,
|
|
28
|
+
withRepeat,
|
|
29
|
+
withTiming,
|
|
30
|
+
} from 'react-native-reanimated';
|
|
31
|
+
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
|
|
32
|
+
import { useCSSVariable } from 'uniwind';
|
|
33
|
+
import { cn } from '../../utils/cn';
|
|
34
|
+
|
|
35
|
+
export interface ShimmerProps extends ViewProps {
|
|
36
|
+
className?: string;
|
|
37
|
+
/** Milliseconds for one sweep. */
|
|
38
|
+
duration?: number;
|
|
39
|
+
/** Sweep once instead of looping. */
|
|
40
|
+
once?: boolean;
|
|
41
|
+
/** Sweep right-to-left. */
|
|
42
|
+
reverse?: boolean;
|
|
43
|
+
/** Width of the highlight band, as a fraction of the content width. */
|
|
44
|
+
spread?: number;
|
|
45
|
+
/** Highlight colour. Defaults to the theme's foreground. */
|
|
46
|
+
color?: string;
|
|
47
|
+
/** Peak opacity of the highlight. */
|
|
48
|
+
intensity?: number;
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function Shimmer({
|
|
53
|
+
className,
|
|
54
|
+
duration = 2000,
|
|
55
|
+
once = false,
|
|
56
|
+
reverse = false,
|
|
57
|
+
spread = 0.4,
|
|
58
|
+
color,
|
|
59
|
+
intensity = 0.45,
|
|
60
|
+
children,
|
|
61
|
+
...props
|
|
62
|
+
}: ShimmerProps) {
|
|
63
|
+
const [width, setWidth] = useState(0);
|
|
64
|
+
const progress = useSharedValue(0);
|
|
65
|
+
const reducedMotion = useReducedMotion();
|
|
66
|
+
|
|
67
|
+
const themeForeground = useCSSVariable('--color-foreground');
|
|
68
|
+
const highlight =
|
|
69
|
+
color ?? (typeof themeForeground === 'string' ? themeForeground : '#ffffff');
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
// Respect the OS setting rather than animating regardless — the same
|
|
73
|
+
// thing shadcn's utility does with prefers-reduced-motion.
|
|
74
|
+
if (reducedMotion || width === 0) return;
|
|
75
|
+
|
|
76
|
+
progress.value = 0;
|
|
77
|
+
progress.value = withRepeat(
|
|
78
|
+
withTiming(1, { duration, easing: Easing.linear }),
|
|
79
|
+
once ? 1 : -1,
|
|
80
|
+
false
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return () => cancelAnimation(progress);
|
|
84
|
+
}, [duration, once, reducedMotion, width, progress]);
|
|
85
|
+
|
|
86
|
+
const bandWidth = Math.max(width * spread, 1);
|
|
87
|
+
|
|
88
|
+
const bandStyle = useAnimatedStyle(() => {
|
|
89
|
+
// Travel from fully off one edge to fully off the other.
|
|
90
|
+
const from = -bandWidth;
|
|
91
|
+
const to = width;
|
|
92
|
+
const x = reverse
|
|
93
|
+
? to - progress.value * (to - from)
|
|
94
|
+
: from + progress.value * (to - from);
|
|
95
|
+
return { transform: [{ translateX: x }] };
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
99
|
+
setWidth(event.nativeEvent.layout.width);
|
|
100
|
+
props.onLayout?.(event);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<View
|
|
105
|
+
{...props}
|
|
106
|
+
onLayout={onLayout}
|
|
107
|
+
className={cn('overflow-hidden', className)}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
|
|
111
|
+
{reducedMotion || width === 0 ? null : (
|
|
112
|
+
<Animated.View
|
|
113
|
+
pointerEvents="none"
|
|
114
|
+
style={[
|
|
115
|
+
{ position: 'absolute', top: 0, bottom: 0, width: bandWidth },
|
|
116
|
+
bandStyle,
|
|
117
|
+
]}
|
|
118
|
+
>
|
|
119
|
+
<Svg width="100%" height="100%">
|
|
120
|
+
<Defs>
|
|
121
|
+
<LinearGradient id="shimmer" x1="0" y1="0" x2="1" y2="0">
|
|
122
|
+
<Stop offset="0" stopColor={highlight} stopOpacity={0} />
|
|
123
|
+
<Stop offset="0.5" stopColor={highlight} stopOpacity={intensity} />
|
|
124
|
+
<Stop offset="1" stopColor={highlight} stopOpacity={0} />
|
|
125
|
+
</LinearGradient>
|
|
126
|
+
</Defs>
|
|
127
|
+
<Rect x="0" y="0" width="100%" height="100%" fill="url(#shimmer)" />
|
|
128
|
+
</Svg>
|
|
129
|
+
</Animated.View>
|
|
130
|
+
)}
|
|
131
|
+
</View>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
Shimmer.displayName = 'Shimmer';
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface — an elevated container.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: heroui-inc/heroui-native src/components/surface/
|
|
5
|
+
* (the variant ladder and the nesting model for building hierarchy).
|
|
6
|
+
*
|
|
7
|
+
* Both the fill and the corner radius follow the active theme: the variants
|
|
8
|
+
* resolve to themed `--color-surface-*` tokens, and `rounded-3xl` resolves
|
|
9
|
+
* through the per-theme radius scale — so Moon's surfaces are tight and
|
|
10
|
+
* Grass's are soft, without the component knowing anything about either.
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <Surface>
|
|
14
|
+
* <Surface variant="secondary">
|
|
15
|
+
* <Surface variant="tertiary">…</Surface>
|
|
16
|
+
* </Surface>
|
|
17
|
+
* </Surface>
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
import { forwardRef } from 'react';
|
|
21
|
+
import { StyleSheet, View, type ViewProps } from 'react-native';
|
|
22
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
23
|
+
|
|
24
|
+
const surfaceVariants = tv({
|
|
25
|
+
base: 'overflow-hidden rounded-3xl p-4',
|
|
26
|
+
variants: {
|
|
27
|
+
variant: {
|
|
28
|
+
default: 'bg-surface',
|
|
29
|
+
secondary: 'bg-surface-secondary',
|
|
30
|
+
tertiary: 'bg-surface-tertiary',
|
|
31
|
+
transparent: 'bg-transparent',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {
|
|
35
|
+
variant: 'default',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export interface SurfaceProps
|
|
40
|
+
extends ViewProps,
|
|
41
|
+
VariantProps<typeof surfaceVariants> {
|
|
42
|
+
className?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Surface = forwardRef<View, SurfaceProps>(
|
|
46
|
+
({ className, variant, style, ...props }, ref) => (
|
|
47
|
+
<View
|
|
48
|
+
ref={ref}
|
|
49
|
+
// `borderCurve` has no Tailwind equivalent. On iOS it gives the
|
|
50
|
+
// continuous (squircle) corner Apple uses, which is visibly smoother
|
|
51
|
+
// than a circular arc at this radius; Android ignores it.
|
|
52
|
+
style={[styles.root, style]}
|
|
53
|
+
className={surfaceVariants({ variant, className })}
|
|
54
|
+
{...props}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
Surface.displayName = 'Surface';
|
|
60
|
+
|
|
61
|
+
const styles = StyleSheet.create({
|
|
62
|
+
root: { borderCurve: 'continuous' },
|
|
63
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
useCopyToClipboard,
|
|
3
|
+
type UseCopyToClipboardOptions,
|
|
4
|
+
type UseCopyToClipboardResult,
|
|
5
|
+
} from './use-copy-to-clipboard';
|
|
6
|
+
export {
|
|
7
|
+
useDisclosure,
|
|
8
|
+
type UseDisclosureOptions,
|
|
9
|
+
type UseDisclosureResult,
|
|
10
|
+
} from './use-disclosure';
|
|
11
|
+
export {
|
|
12
|
+
useBreakpoint,
|
|
13
|
+
BREAKPOINTS,
|
|
14
|
+
type Breakpoint,
|
|
15
|
+
type UseBreakpointResult,
|
|
16
|
+
} from './use-breakpoint';
|
|
17
|
+
export { useKeyboard, type UseKeyboardResult } from './use-keyboard';
|
|
18
|
+
export { useDebouncedValue } from './use-debounced-value';
|
|
19
|
+
export { usePrevious } from './use-previous';
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useWindowDimensions } from 'react-native';
|
|
2
|
+
|
|
3
|
+
/** Tailwind's breakpoints, minus the ones no phone or tablet reaches. */
|
|
4
|
+
export const BREAKPOINTS = { sm: 640, md: 768, lg: 1024, xl: 1280 } as const;
|
|
5
|
+
|
|
6
|
+
export type Breakpoint = keyof typeof BREAKPOINTS;
|
|
7
|
+
|
|
8
|
+
export interface UseBreakpointResult {
|
|
9
|
+
/** Largest breakpoint the window currently satisfies, or `base`. */
|
|
10
|
+
current: Breakpoint | 'base';
|
|
11
|
+
/** True when the window is at least this wide. */
|
|
12
|
+
isAtLeast: (breakpoint: Breakpoint) => boolean;
|
|
13
|
+
width: number;
|
|
14
|
+
height: number;
|
|
15
|
+
isLandscape: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Responsive state from the window size — the React Native answer to
|
|
20
|
+
* coss.com/ui's `useMediaQuery`, which has no equivalent here.
|
|
21
|
+
*
|
|
22
|
+
* For styling, prefer Uniwind's responsive class prefixes (`md:flex-row`).
|
|
23
|
+
* Reach for this when the *behaviour* changes, not just the look — rendering
|
|
24
|
+
* a sheet on a phone and a dialog on a tablet, say.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const { isAtLeast } = useBreakpoint();
|
|
28
|
+
* return isAtLeast('md') ? <Dialog …/> : <BottomSheet …/>;
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function useBreakpoint(): UseBreakpointResult {
|
|
32
|
+
const { width, height } = useWindowDimensions();
|
|
33
|
+
|
|
34
|
+
const current: Breakpoint | 'base' =
|
|
35
|
+
width >= BREAKPOINTS.xl
|
|
36
|
+
? 'xl'
|
|
37
|
+
: width >= BREAKPOINTS.lg
|
|
38
|
+
? 'lg'
|
|
39
|
+
: width >= BREAKPOINTS.md
|
|
40
|
+
? 'md'
|
|
41
|
+
: width >= BREAKPOINTS.sm
|
|
42
|
+
? 'sm'
|
|
43
|
+
: 'base';
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
current,
|
|
47
|
+
isAtLeast: (breakpoint) => width >= BREAKPOINTS[breakpoint],
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
isLandscape: width > height,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UseCopyToClipboardOptions {
|
|
4
|
+
/** Milliseconds before `copied` flips back. */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
onCopy?: () => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseCopyToClipboardResult {
|
|
10
|
+
/** Writes `value` to the clipboard and sets `copied`. */
|
|
11
|
+
copy: (value: string) => Promise<void>;
|
|
12
|
+
copied: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Copy text to the clipboard, with a temporary "copied" state.
|
|
17
|
+
*
|
|
18
|
+
* Mirrors coss.com/ui's `useCopyToClipboard`.
|
|
19
|
+
*
|
|
20
|
+
* Requires `expo-clipboard`, which is an optional peer dependency — React
|
|
21
|
+
* Native removed `Clipboard` from core, and pulling in a required native
|
|
22
|
+
* module for one hook would cost every consumer who never calls it:
|
|
23
|
+
*
|
|
24
|
+
* ```sh
|
|
25
|
+
* npx expo install expo-clipboard
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ```tsx
|
|
29
|
+
* const { copy, copied } = useCopyToClipboard();
|
|
30
|
+
* <Button onPress={() => copy(url)}>{copied ? 'Copied' : 'Copy link'}</Button>
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function useCopyToClipboard({
|
|
34
|
+
timeout = 2000,
|
|
35
|
+
onCopy,
|
|
36
|
+
}: UseCopyToClipboardOptions = {}): UseCopyToClipboardResult {
|
|
37
|
+
const [copied, setCopied] = useState(false);
|
|
38
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
39
|
+
|
|
40
|
+
// Never leave a timer running against an unmounted component.
|
|
41
|
+
useEffect(
|
|
42
|
+
() => () => {
|
|
43
|
+
if (timer.current) clearTimeout(timer.current);
|
|
44
|
+
},
|
|
45
|
+
[]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const copy = useCallback(
|
|
49
|
+
async (value: string) => {
|
|
50
|
+
const setString = await loadClipboard();
|
|
51
|
+
await setString(value);
|
|
52
|
+
|
|
53
|
+
setCopied(true);
|
|
54
|
+
onCopy?.();
|
|
55
|
+
|
|
56
|
+
if (timer.current) clearTimeout(timer.current);
|
|
57
|
+
if (timeout > 0) {
|
|
58
|
+
timer.current = setTimeout(() => setCopied(false), timeout);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[timeout, onCopy]
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return { copy, copied };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolves expo-clipboard lazily, so apps that never call this hook are not
|
|
69
|
+
* asked to install it. The import is awaited rather than top-level for the
|
|
70
|
+
* same reason.
|
|
71
|
+
*/
|
|
72
|
+
async function loadClipboard(): Promise<(value: string) => Promise<unknown>> {
|
|
73
|
+
try {
|
|
74
|
+
const clipboard = await import('expo-clipboard');
|
|
75
|
+
return clipboard.setStringAsync;
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(
|
|
78
|
+
'useCopyToClipboard requires expo-clipboard. Install it with: npx expo install expo-clipboard'
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A copy of `value` that only updates once it has stopped changing for
|
|
5
|
+
* `delay` ms — for search fields, where you want the typed value on screen
|
|
6
|
+
* immediately but the query fired once.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const [query, setQuery] = useState('');
|
|
10
|
+
* const debounced = useDebouncedValue(query, 300);
|
|
11
|
+
* useEffect(() => { search(debounced); }, [debounced]);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
|
15
|
+
const [debounced, setDebounced] = useState(value);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const timer = setTimeout(() => setDebounced(value), delay);
|
|
19
|
+
return () => clearTimeout(timer);
|
|
20
|
+
}, [value, delay]);
|
|
21
|
+
|
|
22
|
+
return debounced;
|
|
23
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export interface UseDisclosureOptions {
|
|
4
|
+
defaultOpen?: boolean;
|
|
5
|
+
/** Controlled open state. Pass this to drive it from outside. */
|
|
6
|
+
open?: boolean;
|
|
7
|
+
onOpenChange?: (open: boolean) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UseDisclosureResult {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
open: () => void;
|
|
13
|
+
close: () => void;
|
|
14
|
+
toggle: () => void;
|
|
15
|
+
/** Spread onto Dialog, BottomSheet or Select. */
|
|
16
|
+
props: { open: boolean; onOpenChange: (open: boolean) => void };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Open/closed state for an overlay.
|
|
21
|
+
*
|
|
22
|
+
* The same controlled-or-uncontrolled pattern Dialog, BottomSheet and Select
|
|
23
|
+
* implement internally, lifted out for when you need to drive one from
|
|
24
|
+
* elsewhere — closing a sheet after a request settles, say.
|
|
25
|
+
*
|
|
26
|
+
* ```tsx
|
|
27
|
+
* const sheet = useDisclosure();
|
|
28
|
+
* <Button onPress={sheet.open}>Share</Button>
|
|
29
|
+
* <BottomSheet {...sheet.props}>…</BottomSheet>
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function useDisclosure({
|
|
33
|
+
defaultOpen = false,
|
|
34
|
+
open: controlledOpen,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
}: UseDisclosureOptions = {}): UseDisclosureResult {
|
|
37
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
38
|
+
const isControlled = controlledOpen !== undefined;
|
|
39
|
+
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
|
40
|
+
|
|
41
|
+
const setOpen = useCallback(
|
|
42
|
+
(next: boolean) => {
|
|
43
|
+
if (!isControlled) setUncontrolledOpen(next);
|
|
44
|
+
onOpenChange?.(next);
|
|
45
|
+
},
|
|
46
|
+
[isControlled, onOpenChange]
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const open = useCallback(() => setOpen(true), [setOpen]);
|
|
50
|
+
const close = useCallback(() => setOpen(false), [setOpen]);
|
|
51
|
+
const toggle = useCallback(() => setOpen(!isOpen), [setOpen, isOpen]);
|
|
52
|
+
|
|
53
|
+
return useMemo(
|
|
54
|
+
() => ({ isOpen, open, close, toggle, props: { open: isOpen, onOpenChange: setOpen } }),
|
|
55
|
+
[isOpen, open, close, toggle, setOpen]
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Keyboard, Platform } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export interface UseKeyboardResult {
|
|
5
|
+
/** Height of the keyboard in px, or 0 when hidden. */
|
|
6
|
+
height: number;
|
|
7
|
+
isVisible: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Keyboard height and visibility, on the JS thread.
|
|
12
|
+
*
|
|
13
|
+
* Use this when the value drives rendering — showing a hint, swapping a
|
|
14
|
+
* layout. For *animating* against the keyboard, use Reanimated's
|
|
15
|
+
* `useAnimatedKeyboard` instead: it runs on the UI thread and tracks the
|
|
16
|
+
* keyboard frame-for-frame, where this fires once per show/hide.
|
|
17
|
+
*
|
|
18
|
+
* ```tsx
|
|
19
|
+
* const { isVisible } = useKeyboard();
|
|
20
|
+
* {isVisible ? null : <Footer />}
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function useKeyboard(): UseKeyboardResult {
|
|
24
|
+
const [height, setHeight] = useState(0);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// iOS reports the frame before it animates; Android only on completion.
|
|
28
|
+
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
29
|
+
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
30
|
+
|
|
31
|
+
const show = Keyboard.addListener(showEvent, (event) =>
|
|
32
|
+
setHeight(event.endCoordinates.height)
|
|
33
|
+
);
|
|
34
|
+
const hide = Keyboard.addListener(hideEvent, () => setHeight(0));
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
show.remove();
|
|
38
|
+
hide.remove();
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return { height, isVisible: height > 0 };
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The value from the previous render, or `undefined` on the first.
|
|
5
|
+
*
|
|
6
|
+
* Useful for reacting to a transition rather than a state — firing only when
|
|
7
|
+
* something *became* true.
|
|
8
|
+
*
|
|
9
|
+
* ```tsx
|
|
10
|
+
* const wasOpen = usePrevious(isOpen);
|
|
11
|
+
* useEffect(() => {
|
|
12
|
+
* if (isOpen && !wasOpen) track('sheet_opened');
|
|
13
|
+
* }, [isOpen, wasOpen]);
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
17
|
+
const ref = useRef<T | undefined>(undefined);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
ref.current = value;
|
|
21
|
+
}, [value]);
|
|
22
|
+
|
|
23
|
+
return ref.current;
|
|
24
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -90,6 +90,11 @@ export {
|
|
|
90
90
|
type RadioGroupProps,
|
|
91
91
|
type RadioGroupItemProps,
|
|
92
92
|
} from './components/radio-group';
|
|
93
|
+
export {
|
|
94
|
+
ScrollFade,
|
|
95
|
+
type ScrollFadeProps,
|
|
96
|
+
} from './components/scroll-fade';
|
|
97
|
+
export { Shimmer, type ShimmerProps } from './components/shimmer';
|
|
93
98
|
export { Skeleton, type SkeletonProps } from './components/skeleton';
|
|
94
99
|
export { Spinner, type SpinnerProps } from './components/spinner';
|
|
95
100
|
export {
|
|
@@ -102,6 +107,7 @@ export {
|
|
|
102
107
|
type StepState,
|
|
103
108
|
type StepsOrientation,
|
|
104
109
|
} from './components/steps';
|
|
110
|
+
export { Surface, type SurfaceProps } from './components/surface';
|
|
105
111
|
export { Switch, type SwitchProps } from './components/switch';
|
|
106
112
|
export {
|
|
107
113
|
Timeline,
|
|
@@ -166,5 +172,8 @@ export {
|
|
|
166
172
|
type IconProps,
|
|
167
173
|
} from './icons';
|
|
168
174
|
|
|
175
|
+
// Hooks
|
|
176
|
+
export * from './hooks';
|
|
177
|
+
|
|
169
178
|
// Utils
|
|
170
179
|
export { cn } from './utils/cn';
|
package/theme.css
CHANGED
|
@@ -40,7 +40,13 @@
|
|
|
40
40
|
--color-border: unset;
|
|
41
41
|
--color-input: unset;
|
|
42
42
|
--color-ring: unset;
|
|
43
|
+
/*
|
|
44
|
+
* Elevation ladder. Each step sits one notch further from the page
|
|
45
|
+
* background than the last, so nesting Surfaces reads as hierarchy.
|
|
46
|
+
*/
|
|
43
47
|
--color-surface: unset;
|
|
48
|
+
--color-surface-secondary: unset;
|
|
49
|
+
--color-surface-tertiary: unset;
|
|
44
50
|
--color-skeleton: unset;
|
|
45
51
|
|
|
46
52
|
/*
|
|
@@ -122,6 +128,8 @@
|
|
|
122
128
|
|
|
123
129
|
/* grouping surface (Frame) and skeleton fill */
|
|
124
130
|
--color-surface: #f7f7f7;
|
|
131
|
+
--color-surface-secondary: #f1f1f1;
|
|
132
|
+
--color-surface-tertiary: #eaeaea;
|
|
125
133
|
--color-skeleton: rgba(0, 0, 0, 0.1);
|
|
126
134
|
|
|
127
135
|
/* status fills: -soft = Alert (10%), -subtle = Badge (8%) */
|
|
@@ -193,6 +201,8 @@
|
|
|
193
201
|
|
|
194
202
|
/* grouping surface (Frame) and skeleton fill */
|
|
195
203
|
--color-surface: #1c1c1c;
|
|
204
|
+
--color-surface-secondary: #222222;
|
|
205
|
+
--color-surface-tertiary: #282828;
|
|
196
206
|
--color-skeleton: rgba(255, 255, 255, 0.13);
|
|
197
207
|
|
|
198
208
|
/* status fills: -soft = Alert (6%), -subtle = Badge (16%) */
|
|
@@ -274,6 +284,8 @@
|
|
|
274
284
|
--color-ring: #171717;
|
|
275
285
|
|
|
276
286
|
--color-surface: #fafafa;
|
|
287
|
+
--color-surface-secondary: #f4f4f4;
|
|
288
|
+
--color-surface-tertiary: #ededed;
|
|
277
289
|
--color-skeleton: rgba(0, 0, 0, 0.08);
|
|
278
290
|
|
|
279
291
|
--color-info-soft: rgba(0, 112, 243, 0.08);
|
|
@@ -334,6 +346,8 @@
|
|
|
334
346
|
--color-ring: #ededed;
|
|
335
347
|
|
|
336
348
|
--color-surface: #0f0f0f;
|
|
349
|
+
--color-surface-secondary: #161616;
|
|
350
|
+
--color-surface-tertiary: #1d1d1d;
|
|
337
351
|
--color-skeleton: rgba(255, 255, 255, 0.11);
|
|
338
352
|
|
|
339
353
|
--color-info-soft: rgba(0, 112, 243, 0.12);
|
|
@@ -397,6 +411,8 @@
|
|
|
397
411
|
--color-ring: #3ecf8e;
|
|
398
412
|
|
|
399
413
|
--color-surface: #f8f9fa;
|
|
414
|
+
--color-surface-secondary: #f1f3f5;
|
|
415
|
+
--color-surface-tertiary: #e9ecef;
|
|
400
416
|
--color-skeleton: rgba(0, 0, 0, 0.08);
|
|
401
417
|
|
|
402
418
|
--color-info-soft: rgba(59, 130, 246, 0.1);
|
|
@@ -457,6 +473,8 @@
|
|
|
457
473
|
--color-ring: #3ecf8e;
|
|
458
474
|
|
|
459
475
|
--color-surface: #242424;
|
|
476
|
+
--color-surface-secondary: #2a2a2a;
|
|
477
|
+
--color-surface-tertiary: #303030;
|
|
460
478
|
--color-skeleton: rgba(255, 255, 255, 0.11);
|
|
461
479
|
|
|
462
480
|
--color-info-soft: rgba(59, 130, 246, 0.08);
|