@atlure/ui 0.4.0 → 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.
Files changed (72) hide show
  1. package/README.md +39 -6
  2. package/package.json +5 -6
  3. package/src/components/alert-dialog/alert-dialog.tsx +59 -0
  4. package/src/components/alert-dialog/utils.ts +5 -0
  5. package/src/components/avatar/avatar-group.tsx +52 -0
  6. package/src/components/avatar/avatar.tsx +54 -11
  7. package/src/components/badge/urgency-badge.tsx +21 -0
  8. package/src/components/calendar/calendar.tsx +189 -0
  9. package/src/components/checkbox/checkbox.tsx +26 -9
  10. package/src/components/chip/chip.tsx +66 -0
  11. package/src/components/date-range-picker/date-range-picker.tsx +104 -0
  12. package/src/components/dialog/dialog.tsx +137 -0
  13. package/src/components/format/date-label.tsx +41 -0
  14. package/src/components/format/distance-label.tsx +13 -0
  15. package/src/components/format/duration-label.tsx +13 -0
  16. package/src/components/format/money-label.tsx +20 -0
  17. package/src/components/picker/picker.tsx +104 -0
  18. package/src/components/progress/progress.tsx +95 -0
  19. package/src/components/progress/utils.ts +10 -0
  20. package/src/components/radio-group/radio-group-context.tsx +27 -0
  21. package/src/components/radio-group/radio-group.tsx +150 -0
  22. package/src/components/screen-header/screen-header.tsx +62 -0
  23. package/src/components/segmented-control/segmented-control.tsx +67 -0
  24. package/src/components/select/select.tsx +134 -0
  25. package/src/components/settings-row/settings-row.tsx +52 -0
  26. package/src/components/sheet/sheet.tsx +139 -0
  27. package/src/components/sheet/utils.ts +55 -0
  28. package/src/components/slider/format-label.ts +3 -0
  29. package/src/components/slider/hooks/use-slider-drag.ts +47 -0
  30. package/src/components/slider/range-slider.tsx +203 -0
  31. package/src/components/slider/slider.tsx +148 -0
  32. package/src/components/slider/utils.ts +57 -0
  33. package/src/components/star-rating/star-rating.tsx +98 -0
  34. package/src/components/switch/switch.tsx +34 -6
  35. package/src/components/tabs/tabs-context.tsx +32 -0
  36. package/src/components/tabs/tabs.tsx +239 -0
  37. package/src/components/time-picker/time-picker.tsx +131 -0
  38. package/src/components/toast/toast-context.tsx +26 -0
  39. package/src/components/toast/toast-provider.tsx +70 -0
  40. package/src/components/toast/toast.tsx +41 -0
  41. package/src/components/toast/utils.ts +2 -0
  42. package/src/index.ts +54 -0
  43. package/src/lib/calendar.ts +204 -0
  44. package/src/lib/format/date.ts +46 -0
  45. package/src/lib/format/distance.ts +36 -0
  46. package/src/lib/format/duration.ts +28 -0
  47. package/src/lib/format/money.ts +20 -0
  48. package/src/lib/locale.tsx +36 -0
  49. package/src/lib/portal.tsx +54 -0
  50. package/src/lib/touch-target.ts +5 -1
  51. package/src/lib/use-sheet.ts +18 -0
  52. package/src/lib/use-toast.ts +17 -0
  53. package/src/variants/avatar-variants.ts +68 -15
  54. package/src/variants/badge-variants.ts +8 -0
  55. package/src/variants/calendar-variants.ts +26 -0
  56. package/src/variants/checkbox-variants.ts +5 -14
  57. package/src/variants/chip-variants.ts +38 -0
  58. package/src/variants/dialog-variants.ts +11 -0
  59. package/src/variants/index.ts +9 -0
  60. package/src/variants/overlay-variants.ts +1 -0
  61. package/src/variants/progress-variants.ts +18 -0
  62. package/src/variants/radio-group-variants.ts +48 -0
  63. package/src/variants/screen-header-variants.ts +33 -0
  64. package/src/variants/segmented-control-variants.ts +41 -0
  65. package/src/variants/select-variants.ts +48 -0
  66. package/src/variants/settings-row-variants.ts +18 -0
  67. package/src/variants/sheet-variants.ts +10 -0
  68. package/src/variants/slider-variants.ts +8 -0
  69. package/src/variants/star-rating-variants.ts +28 -0
  70. package/src/variants/switch-variants.ts +26 -4
  71. package/src/variants/tabs-variants.ts +46 -0
  72. package/src/variants/toast-variants.ts +31 -0
@@ -0,0 +1,150 @@
1
+ import { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
2
+ import { Pressable, type PressableProps, View, type ViewProps } from "react-native";
3
+
4
+ import { cn } from "../../lib/cn";
5
+ import { touchTargetHitSlopForSize } from "../../lib/touch-target";
6
+ import {
7
+ radioGroupDotClassName,
8
+ radioGroupIndicatorSize,
9
+ radioGroupIndicatorVariants,
10
+ radioGroupItemVariants,
11
+ radioGroupVariants,
12
+ } from "../../variants/radio-group-variants";
13
+ import { Label } from "../label/label";
14
+ import { RadioGroupProvider, useRadioGroupControl } from "./radio-group-context";
15
+
16
+ const previousKeys = new Set(["ArrowUp", "ArrowLeft"]);
17
+ const nextKeys = new Set(["ArrowDown", "ArrowRight"]);
18
+
19
+ interface KeyboardEventLike {
20
+ key: string;
21
+ preventDefault: () => void;
22
+ }
23
+
24
+ function webKeyboardProps(onKeyDown: (event: KeyboardEventLike) => void): PressableProps {
25
+ return { onKeyDown } as PressableProps;
26
+ }
27
+
28
+ export interface RadioGroupProps extends Omit<ViewProps, "accessibilityRole"> {
29
+ value?: string;
30
+ onValueChange: (value: string) => void;
31
+ isDisabled?: boolean;
32
+ children: ReactNode;
33
+ }
34
+
35
+ export function RadioGroup({
36
+ value,
37
+ onValueChange,
38
+ isDisabled = false,
39
+ className,
40
+ children,
41
+ ...viewProps
42
+ }: RadioGroupProps) {
43
+ const orderedValues = useRef<string[]>([]);
44
+
45
+ const registerValue = useCallback((itemValue: string) => {
46
+ orderedValues.current = [...orderedValues.current, itemValue];
47
+
48
+ return () => {
49
+ orderedValues.current = orderedValues.current.filter((entry) => entry !== itemValue);
50
+ };
51
+ }, []);
52
+
53
+ const selectAdjacent = useCallback(
54
+ (fromValue: string, direction: 1 | -1) => {
55
+ const values = orderedValues.current;
56
+ const currentIndex = values.indexOf(fromValue);
57
+
58
+ if (currentIndex === -1) {
59
+ return;
60
+ }
61
+
62
+ const nextValue = values[(currentIndex + direction + values.length) % values.length];
63
+
64
+ if (nextValue !== undefined) {
65
+ onValueChange(nextValue);
66
+ }
67
+ },
68
+ [onValueChange],
69
+ );
70
+
71
+ const control = useMemo(
72
+ () => ({
73
+ selectedValue: value,
74
+ isDisabled,
75
+ select: onValueChange,
76
+ registerValue,
77
+ selectAdjacent,
78
+ }),
79
+ [value, isDisabled, onValueChange, registerValue, selectAdjacent],
80
+ );
81
+
82
+ return (
83
+ <View
84
+ accessibilityRole="radiogroup"
85
+ role="radiogroup"
86
+ className={cn(radioGroupVariants({ isDisabled }), className)}
87
+ {...viewProps}
88
+ >
89
+ <RadioGroupProvider control={control}>{children}</RadioGroupProvider>
90
+ </View>
91
+ );
92
+ }
93
+
94
+ export interface RadioGroupItemProps
95
+ extends Omit<
96
+ PressableProps,
97
+ "children" | "disabled" | "onPress" | "accessibilityState" | "accessibilityRole"
98
+ > {
99
+ value: string;
100
+ label?: string;
101
+ isDisabled?: boolean;
102
+ }
103
+
104
+ export function RadioGroupItem({
105
+ value,
106
+ label,
107
+ isDisabled = false,
108
+ accessibilityLabel,
109
+ className,
110
+ ...pressableProps
111
+ }: RadioGroupItemProps) {
112
+ const control = useRadioGroupControl();
113
+
114
+ useEffect(() => control?.registerValue(value), [control, value]);
115
+
116
+ const isSelected = control?.selectedValue === value;
117
+ const isItemDisabled = isDisabled || control?.isDisabled === true;
118
+
119
+ const handleKeyDown = (event: KeyboardEventLike) => {
120
+ const direction = previousKeys.has(event.key) ? -1 : nextKeys.has(event.key) ? 1 : undefined;
121
+
122
+ if (direction === undefined) {
123
+ return;
124
+ }
125
+
126
+ event.preventDefault();
127
+ control?.selectAdjacent(value, direction);
128
+ };
129
+
130
+ return (
131
+ <Pressable
132
+ accessibilityRole="radio"
133
+ accessibilityLabel={accessibilityLabel ?? label}
134
+ accessibilityState={{ checked: isSelected, disabled: isItemDisabled }}
135
+ aria-checked={isSelected}
136
+ aria-disabled={isItemDisabled}
137
+ disabled={isItemDisabled}
138
+ hitSlop={touchTargetHitSlopForSize(radioGroupIndicatorSize)}
139
+ onPress={() => control?.select(value)}
140
+ className={cn(radioGroupItemVariants({ isDisabled: isItemDisabled }), className)}
141
+ {...webKeyboardProps(handleKeyDown)}
142
+ {...pressableProps}
143
+ >
144
+ <View className={radioGroupIndicatorVariants({ isSelected })}>
145
+ {isSelected ? <View className={radioGroupDotClassName} /> : null}
146
+ </View>
147
+ {label ? <Label isDisabled={isItemDisabled}>{label}</Label> : null}
148
+ </Pressable>
149
+ );
150
+ }
@@ -0,0 +1,62 @@
1
+ import { ChevronLeft, iconSize } from "@atlure/icons";
2
+ import type { ReactNode } from "react";
3
+ import { View, type ViewProps } from "react-native";
4
+
5
+ import { cn } from "../../lib/cn";
6
+ import {
7
+ screenHeaderActionsClassName,
8
+ screenHeaderSubtitleClassName,
9
+ screenHeaderTextClassName,
10
+ screenHeaderTitleVariants,
11
+ screenHeaderVariants,
12
+ type ScreenHeaderVariantProps,
13
+ } from "../../variants/screen-header-variants";
14
+ import { IconButton } from "../icon-button/icon-button";
15
+ import { Text } from "../text/text";
16
+
17
+ export const SCREEN_HEADER_BACK_LABEL = "Go back";
18
+
19
+ export interface ScreenHeaderProps extends ViewProps {
20
+ title: string;
21
+ subtitle?: string;
22
+ variant?: NonNullable<ScreenHeaderVariantProps["variant"]>;
23
+ onBack?: () => void;
24
+ backAccessibilityLabel?: string;
25
+ right?: ReactNode;
26
+ topInset?: number;
27
+ }
28
+
29
+ export function ScreenHeader({
30
+ title,
31
+ subtitle,
32
+ variant = "default",
33
+ onBack,
34
+ backAccessibilityLabel = SCREEN_HEADER_BACK_LABEL,
35
+ right,
36
+ topInset = 0,
37
+ className,
38
+ style,
39
+ ...viewProps
40
+ }: ScreenHeaderProps) {
41
+ return (
42
+ <View
43
+ className={cn(screenHeaderVariants({ variant }), className)}
44
+ style={[{ paddingTop: topInset }, style]}
45
+ {...viewProps}
46
+ >
47
+ {onBack ? (
48
+ <IconButton
49
+ accessibilityLabel={backAccessibilityLabel}
50
+ icon={<ChevronLeft size={iconSize.md} />}
51
+ onPress={onBack}
52
+ variant="ghost"
53
+ />
54
+ ) : null}
55
+ <View className={screenHeaderTextClassName}>
56
+ <Text className={screenHeaderTitleVariants({ variant })}>{title}</Text>
57
+ {subtitle ? <Text className={screenHeaderSubtitleClassName}>{subtitle}</Text> : null}
58
+ </View>
59
+ {right ? <View className={screenHeaderActionsClassName}>{right}</View> : null}
60
+ </View>
61
+ );
62
+ }
@@ -0,0 +1,67 @@
1
+ import { Pressable, View, type ViewProps } from "react-native";
2
+
3
+ import { cn } from "../../lib/cn";
4
+ import {
5
+ segmentedControlClassName,
6
+ segmentedControlLabelVariants,
7
+ segmentedControlSegmentVariants,
8
+ } from "../../variants/segmented-control-variants";
9
+ import { Text } from "../text/text";
10
+
11
+ export interface SegmentedControlOption {
12
+ value: string;
13
+ label: string;
14
+ isDisabled?: boolean;
15
+ }
16
+
17
+ export interface SegmentedControlProps extends Omit<ViewProps, "children"> {
18
+ accessibilityLabel: string;
19
+ options: readonly SegmentedControlOption[];
20
+ value: string;
21
+ onValueChange: (value: string) => void;
22
+ isDisabled?: boolean;
23
+ }
24
+
25
+ export function SegmentedControl({
26
+ accessibilityLabel,
27
+ options,
28
+ value,
29
+ onValueChange,
30
+ isDisabled = false,
31
+ className,
32
+ ...viewProps
33
+ }: SegmentedControlProps) {
34
+ return (
35
+ <View
36
+ accessibilityRole="tablist"
37
+ role="tablist"
38
+ accessibilityLabel={accessibilityLabel}
39
+ className={cn(segmentedControlClassName, className)}
40
+ {...viewProps}
41
+ >
42
+ {options.map((option) => {
43
+ const isSelected = option.value === value;
44
+ const isSegmentDisabled = isDisabled || option.isDisabled === true;
45
+
46
+ return (
47
+ <Pressable
48
+ key={option.value}
49
+ accessibilityRole="tab"
50
+ accessibilityLabel={option.label}
51
+ accessibilityState={{ selected: isSelected, disabled: isSegmentDisabled }}
52
+ aria-selected={isSelected}
53
+ aria-disabled={isSegmentDisabled}
54
+ disabled={isSegmentDisabled}
55
+ onPress={() => onValueChange(option.value)}
56
+ className={segmentedControlSegmentVariants({
57
+ isSelected,
58
+ isDisabled: isSegmentDisabled,
59
+ })}
60
+ >
61
+ <Text className={segmentedControlLabelVariants({ isSelected })}>{option.label}</Text>
62
+ </Pressable>
63
+ );
64
+ })}
65
+ </View>
66
+ );
67
+ }
@@ -0,0 +1,134 @@
1
+ import { Check, iconSize } from "@atlure/icons";
2
+ import { Pressable, ScrollView, View } from "react-native";
3
+
4
+ import { cn } from "../../lib/cn";
5
+ import { touchTargetHitSlop } from "../../lib/touch-target";
6
+ import { useSheet } from "../../lib/use-sheet";
7
+ import { inputVariants, type InputVariantProps } from "../../variants/input-variants";
8
+ import {
9
+ selectItemLabelVariants,
10
+ selectItemVariants,
11
+ selectOptionListClassName,
12
+ selectTriggerClassName,
13
+ selectValueVariants,
14
+ } from "../../variants/select-variants";
15
+ import { Sheet } from "../sheet/sheet";
16
+ import { Text } from "../text/text";
17
+
18
+ export type SelectValue = string | number;
19
+
20
+ export interface SelectOption<TValue extends SelectValue> {
21
+ value: TValue;
22
+ label: string;
23
+ isDisabled?: boolean;
24
+ }
25
+
26
+ export interface SelectItemProps {
27
+ label: string;
28
+ isSelected: boolean;
29
+ isDisabled?: boolean;
30
+ onPress: () => void;
31
+ }
32
+
33
+ export function SelectItem({ label, isSelected, isDisabled = false, onPress }: SelectItemProps) {
34
+ return (
35
+ <Pressable
36
+ accessibilityRole="menuitem"
37
+ accessibilityLabel={label}
38
+ accessibilityState={{ selected: isSelected, disabled: isDisabled }}
39
+ aria-selected={isSelected}
40
+ aria-disabled={isDisabled}
41
+ disabled={isDisabled}
42
+ hitSlop={touchTargetHitSlop("md")}
43
+ onPress={onPress}
44
+ className={selectItemVariants({ isDisabled })}
45
+ >
46
+ <Text className={selectItemLabelVariants({ isSelected })}>{label}</Text>
47
+ {isSelected ? <Check size={iconSize.md} /> : null}
48
+ </Pressable>
49
+ );
50
+ }
51
+
52
+ export interface SelectProps<TValue extends SelectValue> {
53
+ options: readonly SelectOption<TValue>[];
54
+ value: NoInfer<TValue> | null;
55
+ onValueChange: (value: NoInfer<TValue>) => void;
56
+ placeholder: string;
57
+ accessibilityLabel: string;
58
+ backdropAccessibilityLabel: string;
59
+ size?: NonNullable<InputVariantProps["size"]>;
60
+ isDisabled?: boolean;
61
+ isInvalid?: boolean;
62
+ snapPoints?: readonly number[];
63
+ bottomInset?: number;
64
+ className?: string;
65
+ }
66
+
67
+ export function Select<TValue extends SelectValue>({
68
+ options,
69
+ value,
70
+ onValueChange,
71
+ placeholder,
72
+ accessibilityLabel,
73
+ backdropAccessibilityLabel,
74
+ size = "md",
75
+ isDisabled = false,
76
+ isInvalid = false,
77
+ snapPoints,
78
+ bottomInset,
79
+ className,
80
+ }: SelectProps<TValue>) {
81
+ const sheet = useSheet();
82
+ const selectedOption = options.find((option) => option.value === value) ?? null;
83
+
84
+ return (
85
+ <>
86
+ <Pressable
87
+ accessibilityRole="combobox"
88
+ accessibilityLabel={accessibilityLabel}
89
+ accessibilityState={{ disabled: isDisabled, expanded: sheet.isOpen }}
90
+ accessibilityValue={{ text: selectedOption?.label ?? placeholder }}
91
+ aria-disabled={isDisabled}
92
+ aria-expanded={sheet.isOpen}
93
+ aria-invalid={isInvalid}
94
+ disabled={isDisabled}
95
+ hitSlop={touchTargetHitSlop(size)}
96
+ onPress={sheet.open}
97
+ className={cn(
98
+ inputVariants({ size, isInvalid, isDisabled }),
99
+ selectTriggerClassName,
100
+ className,
101
+ )}
102
+ >
103
+ <Text className={selectValueVariants({ isPlaceholder: selectedOption === null })}>
104
+ {selectedOption?.label ?? placeholder}
105
+ </Text>
106
+ </Pressable>
107
+ <Sheet
108
+ isOpen={sheet.isOpen}
109
+ onClose={sheet.close}
110
+ accessibilityLabel={accessibilityLabel}
111
+ backdropAccessibilityLabel={backdropAccessibilityLabel}
112
+ snapPoints={snapPoints}
113
+ bottomInset={bottomInset}
114
+ >
115
+ <ScrollView className={selectOptionListClassName}>
116
+ <View accessibilityRole="menu">
117
+ {options.map((option) => (
118
+ <SelectItem
119
+ key={option.value}
120
+ label={option.label}
121
+ isSelected={option.value === value}
122
+ isDisabled={option.isDisabled}
123
+ onPress={() => {
124
+ onValueChange(option.value);
125
+ sheet.close();
126
+ }}
127
+ />
128
+ ))}
129
+ </View>
130
+ </ScrollView>
131
+ </Sheet>
132
+ </>
133
+ );
134
+ }
@@ -0,0 +1,52 @@
1
+ import { useId, type ReactNode } from "react";
2
+ import { View, type ViewProps } from "react-native";
3
+
4
+ import { cn } from "../../lib/cn";
5
+ import {
6
+ settingsRowControlClassName,
7
+ settingsRowTextClassName,
8
+ settingsRowVariants,
9
+ } from "../../variants/settings-row-variants";
10
+ import { Text } from "../text/text";
11
+
12
+ export interface SettingsRowProps extends ViewProps {
13
+ title: string;
14
+ description?: string;
15
+ control: ReactNode;
16
+ isDisabled?: boolean;
17
+ }
18
+
19
+ export function SettingsRow({
20
+ title,
21
+ description,
22
+ control,
23
+ isDisabled = false,
24
+ className,
25
+ ...viewProps
26
+ }: SettingsRowProps) {
27
+ const titleId = useId();
28
+ const descriptionId = useId();
29
+
30
+ return (
31
+ <View className={cn(settingsRowVariants({ isDisabled }), className)} {...viewProps}>
32
+ <View className={settingsRowTextClassName}>
33
+ <Text nativeID={titleId} variant="body">
34
+ {title}
35
+ </Text>
36
+ {description ? (
37
+ <Text nativeID={descriptionId} tone="muted" variant="bodySm">
38
+ {description}
39
+ </Text>
40
+ ) : null}
41
+ </View>
42
+ <View
43
+ accessibilityLabelledBy={titleId}
44
+ aria-labelledby={titleId}
45
+ aria-describedby={description ? descriptionId : undefined}
46
+ className={settingsRowControlClassName}
47
+ >
48
+ {control}
49
+ </View>
50
+ </View>
51
+ );
52
+ }
@@ -0,0 +1,139 @@
1
+ import { spacing } from "@atlure/tokens";
2
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ Animated,
5
+ BackHandler,
6
+ Modal,
7
+ PanResponder,
8
+ Platform,
9
+ Pressable,
10
+ useWindowDimensions,
11
+ View,
12
+ } from "react-native";
13
+
14
+ import { cn } from "../../lib/cn";
15
+ import {
16
+ sheetBackdropClassName,
17
+ sheetContainerClassName,
18
+ sheetContentClassName,
19
+ sheetHandleClassName,
20
+ } from "../../variants/sheet-variants";
21
+ import { useReducedMotion } from "../skeleton/hooks/use-reduced-motion";
22
+ import {
23
+ DEFAULT_SHEET_SNAP_POINTS,
24
+ resolveSnapHeights,
25
+ resolveSnapRelease,
26
+ sheetAnimationDuration,
27
+ SHEET_PAN_ACTIVATION_DISTANCE,
28
+ } from "./utils";
29
+
30
+ export interface SheetProps {
31
+ isOpen: boolean;
32
+ onClose: () => void;
33
+ backdropAccessibilityLabel: string;
34
+ accessibilityLabel?: string;
35
+ snapPoints?: readonly number[];
36
+ bottomInset?: number;
37
+ className?: string;
38
+ children: ReactNode;
39
+ }
40
+
41
+ export function Sheet({
42
+ isOpen,
43
+ onClose,
44
+ backdropAccessibilityLabel,
45
+ accessibilityLabel,
46
+ snapPoints = DEFAULT_SHEET_SNAP_POINTS,
47
+ bottomInset = 0,
48
+ className,
49
+ children,
50
+ }: SheetProps) {
51
+ const { height: windowHeight } = useWindowDimensions();
52
+ const isReducedMotion = useReducedMotion();
53
+ const [activeSnapIndex, setActiveSnapIndex] = useState(0);
54
+ const translateY = useRef(new Animated.Value(windowHeight)).current;
55
+
56
+ const snapHeights = useMemo(
57
+ () => resolveSnapHeights(snapPoints, windowHeight),
58
+ [snapPoints, windowHeight],
59
+ );
60
+ const sheetHeight = snapHeights[activeSnapIndex] ?? snapHeights[0];
61
+ const animationDuration = sheetAnimationDuration(isReducedMotion);
62
+
63
+ useEffect(() => {
64
+ Animated.timing(translateY, {
65
+ toValue: isOpen ? 0 : windowHeight,
66
+ duration: animationDuration,
67
+ useNativeDriver: true,
68
+ }).start();
69
+ }, [isOpen, animationDuration, translateY, windowHeight]);
70
+
71
+ useEffect(() => {
72
+ if (!isOpen || Platform.OS !== "android") return;
73
+
74
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
75
+ onClose();
76
+ return true;
77
+ });
78
+
79
+ return () => subscription.remove();
80
+ }, [isOpen, onClose]);
81
+
82
+ const panResponder = useMemo(
83
+ () =>
84
+ PanResponder.create({
85
+ onMoveShouldSetPanResponder: (_event, gesture) =>
86
+ Math.abs(gesture.dy) > SHEET_PAN_ACTIVATION_DISTANCE,
87
+ onPanResponderMove: (_event, gesture) => {
88
+ if (gesture.dy > 0) translateY.setValue(gesture.dy);
89
+ },
90
+ onPanResponderRelease: (_event, gesture) => {
91
+ const release = resolveSnapRelease({
92
+ activeIndex: activeSnapIndex,
93
+ dragDistance: gesture.dy,
94
+ snapHeights,
95
+ });
96
+
97
+ if (release.shouldDismiss) {
98
+ onClose();
99
+ return;
100
+ }
101
+
102
+ setActiveSnapIndex(release.index);
103
+ Animated.timing(translateY, {
104
+ toValue: 0,
105
+ duration: animationDuration,
106
+ useNativeDriver: true,
107
+ }).start();
108
+ },
109
+ }),
110
+ [activeSnapIndex, animationDuration, onClose, snapHeights, translateY],
111
+ );
112
+
113
+ return (
114
+ <Modal visible={isOpen} transparent animationType="none" onRequestClose={onClose}>
115
+ <View className={sheetContainerClassName}>
116
+ <Pressable
117
+ accessibilityRole="button"
118
+ accessibilityLabel={backdropAccessibilityLabel}
119
+ className={sheetBackdropClassName}
120
+ onPress={onClose}
121
+ />
122
+ <Animated.View
123
+ accessibilityViewIsModal
124
+ accessibilityLabel={accessibilityLabel}
125
+ className={cn(sheetContentClassName, className)}
126
+ style={{
127
+ height: sheetHeight,
128
+ paddingBottom: spacing.md + bottomInset,
129
+ transform: [{ translateY }],
130
+ }}
131
+ {...panResponder.panHandlers}
132
+ >
133
+ <View className={sheetHandleClassName} />
134
+ {children}
135
+ </Animated.View>
136
+ </View>
137
+ </Modal>
138
+ );
139
+ }
@@ -0,0 +1,55 @@
1
+ export const SHEET_ANIMATION_DURATION = 220;
2
+ export const SHEET_DISMISS_HEIGHT_RATIO = 0.7;
3
+ export const SHEET_PAN_ACTIVATION_DISTANCE = 8;
4
+ export const DEFAULT_SHEET_SNAP_POINTS = [0.5] as const;
5
+
6
+ export function sheetAnimationDuration(isReducedMotion: boolean): number {
7
+ return isReducedMotion ? 0 : SHEET_ANIMATION_DURATION;
8
+ }
9
+
10
+ export function resolveSnapHeights(
11
+ snapPoints: readonly number[],
12
+ containerHeight: number,
13
+ ): number[] {
14
+ const heights = snapPoints
15
+ .filter((point) => point > 0)
16
+ .map((point) => Math.min(point, 1) * containerHeight)
17
+ .sort((left, right) => left - right);
18
+
19
+ return heights.length > 0 ? heights : [containerHeight * DEFAULT_SHEET_SNAP_POINTS[0]];
20
+ }
21
+
22
+ export interface SnapRelease {
23
+ index: number;
24
+ shouldDismiss: boolean;
25
+ }
26
+
27
+ export function resolveSnapRelease({
28
+ activeIndex,
29
+ dragDistance,
30
+ snapHeights,
31
+ dismissHeightRatio = SHEET_DISMISS_HEIGHT_RATIO,
32
+ }: {
33
+ activeIndex: number;
34
+ dragDistance: number;
35
+ snapHeights: readonly number[];
36
+ dismissHeightRatio?: number;
37
+ }): SnapRelease {
38
+ const smallestHeight = snapHeights[0] ?? 0;
39
+ const currentHeight = snapHeights[activeIndex] ?? smallestHeight;
40
+ const releasedHeight = currentHeight - dragDistance;
41
+
42
+ if (releasedHeight < smallestHeight * dismissHeightRatio) {
43
+ return { index: activeIndex, shouldDismiss: true };
44
+ }
45
+
46
+ const nearestIndex = snapHeights.reduce(
47
+ (nearest, height, index) =>
48
+ Math.abs(height - releasedHeight) < Math.abs((snapHeights[nearest] ?? 0) - releasedHeight)
49
+ ? index
50
+ : nearest,
51
+ 0,
52
+ );
53
+
54
+ return { index: nearestIndex, shouldDismiss: false };
55
+ }
@@ -0,0 +1,3 @@
1
+ export type SliderFormatLabel = (value: number) => string;
2
+
3
+ export type RangeSliderFormatLabel = (value: number, thumb: "lower" | "upper") => string;