@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.
- package/README.md +39 -6
- package/package.json +5 -6
- package/src/components/alert-dialog/alert-dialog.tsx +59 -0
- package/src/components/alert-dialog/utils.ts +5 -0
- package/src/components/avatar/avatar-group.tsx +52 -0
- package/src/components/avatar/avatar.tsx +54 -11
- package/src/components/badge/urgency-badge.tsx +21 -0
- package/src/components/calendar/calendar.tsx +189 -0
- package/src/components/checkbox/checkbox.tsx +26 -9
- package/src/components/chip/chip.tsx +66 -0
- package/src/components/date-range-picker/date-range-picker.tsx +104 -0
- package/src/components/dialog/dialog.tsx +137 -0
- package/src/components/format/date-label.tsx +41 -0
- package/src/components/format/distance-label.tsx +13 -0
- package/src/components/format/duration-label.tsx +13 -0
- package/src/components/format/money-label.tsx +20 -0
- package/src/components/picker/picker.tsx +104 -0
- package/src/components/progress/progress.tsx +95 -0
- package/src/components/progress/utils.ts +10 -0
- package/src/components/radio-group/radio-group-context.tsx +27 -0
- package/src/components/radio-group/radio-group.tsx +150 -0
- package/src/components/screen-header/screen-header.tsx +62 -0
- package/src/components/segmented-control/segmented-control.tsx +67 -0
- package/src/components/select/select.tsx +134 -0
- package/src/components/settings-row/settings-row.tsx +52 -0
- package/src/components/sheet/sheet.tsx +139 -0
- package/src/components/sheet/utils.ts +55 -0
- package/src/components/slider/format-label.ts +3 -0
- package/src/components/slider/hooks/use-slider-drag.ts +47 -0
- package/src/components/slider/range-slider.tsx +203 -0
- package/src/components/slider/slider.tsx +148 -0
- package/src/components/slider/utils.ts +57 -0
- package/src/components/star-rating/star-rating.tsx +98 -0
- package/src/components/switch/switch.tsx +34 -6
- package/src/components/tabs/tabs-context.tsx +32 -0
- package/src/components/tabs/tabs.tsx +239 -0
- package/src/components/time-picker/time-picker.tsx +131 -0
- package/src/components/toast/toast-context.tsx +26 -0
- package/src/components/toast/toast-provider.tsx +70 -0
- package/src/components/toast/toast.tsx +41 -0
- package/src/components/toast/utils.ts +2 -0
- package/src/index.ts +54 -0
- package/src/lib/calendar.ts +204 -0
- package/src/lib/format/date.ts +46 -0
- package/src/lib/format/distance.ts +36 -0
- package/src/lib/format/duration.ts +28 -0
- package/src/lib/format/money.ts +20 -0
- package/src/lib/locale.tsx +36 -0
- package/src/lib/portal.tsx +54 -0
- package/src/lib/touch-target.ts +5 -1
- package/src/lib/use-sheet.ts +18 -0
- package/src/lib/use-toast.ts +17 -0
- package/src/variants/avatar-variants.ts +68 -15
- package/src/variants/badge-variants.ts +8 -0
- package/src/variants/calendar-variants.ts +26 -0
- package/src/variants/checkbox-variants.ts +5 -14
- package/src/variants/chip-variants.ts +38 -0
- package/src/variants/dialog-variants.ts +11 -0
- package/src/variants/index.ts +9 -0
- package/src/variants/overlay-variants.ts +1 -0
- package/src/variants/progress-variants.ts +18 -0
- package/src/variants/radio-group-variants.ts +48 -0
- package/src/variants/screen-header-variants.ts +33 -0
- package/src/variants/segmented-control-variants.ts +41 -0
- package/src/variants/select-variants.ts +48 -0
- package/src/variants/settings-row-variants.ts +18 -0
- package/src/variants/sheet-variants.ts +10 -0
- package/src/variants/slider-variants.ts +8 -0
- package/src/variants/star-rating-variants.ts +28 -0
- package/src/variants/switch-variants.ts +26 -4
- package/src/variants/tabs-variants.ts +46 -0
- 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
|
+
}
|