@ecohouse/ui 0.1.18 → 0.1.20
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/dist/index.cjs +1089 -280
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +61 -4
- package/dist/index.d.ts +61 -4
- package/dist/index.js +1035 -231
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Range/Range.stories.tsx +128 -0
- package/src/components/Range/Range.test.ts +78 -0
- package/src/components/Range/Range.tsx +755 -0
- package/src/components/Range/Range.types.ts +35 -0
- package/src/components/Range/Range.utils.ts +56 -0
- package/src/components/Range/index.ts +2 -0
- package/src/components/StatCard/StatCard.tsx +2 -1
- package/src/components/index.ts +3 -0
- package/src/icons/Close/CloseIcon.tsx +18 -0
- package/src/icons/Close/CloseIcon.web.tsx +17 -0
- package/src/icons/Close/closePath.ts +2 -0
- package/src/icons/Close/index.ts +2 -0
- package/src/icons/CottageCard/CottageCardIcons.tsx +8 -1
- package/src/icons/CottageCard/CottageCardIcons.web.tsx +8 -1
- package/src/icons/MapControls/MapControlIcons.tsx +31 -0
- package/src/icons/MapControls/MapControlIcons.web.tsx +30 -0
- package/src/icons/MapControls/index.ts +2 -0
- package/src/icons/MapControls/mapControlPaths.ts +4 -0
- package/src/icons/index.ts +2 -0
- package/src/icons/registry.ts +8 -0
- package/src/index.ts +13 -1
- package/src/primitives/FavoritesButton/FavoritesButton.stories.tsx +60 -0
- package/src/primitives/FavoritesButton/FavoritesButton.tsx +120 -0
- package/src/primitives/FavoritesButton/index.ts +2 -0
- package/src/primitives/index.ts +2 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { StyleProp, ViewProps, ViewStyle } from "react-native";
|
|
2
|
+
|
|
3
|
+
export interface RangeValue {
|
|
4
|
+
from?: number;
|
|
5
|
+
to?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RangeProps extends Omit<ViewProps, "style" | "children"> {
|
|
9
|
+
/** Lowest value represented by the track. */
|
|
10
|
+
min?: number;
|
|
11
|
+
/** Highest value represented by the track. */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** Snapping increment for handle drag, track click, and accessibility actions. */
|
|
14
|
+
step?: number;
|
|
15
|
+
value?: RangeValue;
|
|
16
|
+
defaultValue?: RangeValue;
|
|
17
|
+
onValueChange?: (value: RangeValue) => void;
|
|
18
|
+
/** Localized empty-state text for the lower-bound input. Defaults to Armenian. */
|
|
19
|
+
fromPlaceholder?: string;
|
|
20
|
+
/** Localized empty-state text for the upper-bound input. Defaults to Armenian. */
|
|
21
|
+
toPlaceholder?: string;
|
|
22
|
+
/** Currency text rendered after both inputs. */
|
|
23
|
+
currency?: string;
|
|
24
|
+
/** Localized accessible name for the lower handle and input. Defaults to `fromPlaceholder`. */
|
|
25
|
+
fromAccessibilityLabel?: string;
|
|
26
|
+
/** Localized accessible name for the upper handle and input. Defaults to `toPlaceholder`. */
|
|
27
|
+
toAccessibilityLabel?: string;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
/** Locale-aware display formatter. Keep this paired with `parseValue` when it adds separators. */
|
|
30
|
+
formatValue?: (value: number) => string;
|
|
31
|
+
/** Parses localized input text back to a numeric value. */
|
|
32
|
+
parseValue?: (value: string) => number | undefined;
|
|
33
|
+
style?: StyleProp<ViewStyle>;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RangeValue } from "./Range.types";
|
|
2
|
+
|
|
3
|
+
export type ParsedRangeDraft =
|
|
4
|
+
{ kind: "empty" } | { kind: "invalid" } | { kind: "value"; value: number };
|
|
5
|
+
|
|
6
|
+
function decimals(value: number) {
|
|
7
|
+
const [coefficient, exponentText = "0"] = Math.abs(value).toString().toLowerCase().split("e");
|
|
8
|
+
const [, fraction = ""] = coefficient.split(".");
|
|
9
|
+
return Math.max(0, fraction.length - Number(exponentText));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function clampAndSnap(value: number, min: number, max: number, step: number) {
|
|
13
|
+
const clamped = Math.min(max, Math.max(min, value));
|
|
14
|
+
const snapped = min + Math.round((clamped - min) / step) * step;
|
|
15
|
+
const precision = Math.min(15, Math.max(decimals(min), decimals(step)));
|
|
16
|
+
return Number(Math.min(max, Math.max(min, snapped)).toFixed(precision));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function clampRangeValue(value: number, min: number, max: number) {
|
|
20
|
+
return Math.min(max, Math.max(min, value));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function snapRangeDelta(start: number, delta: number, step: number) {
|
|
24
|
+
const steps = Math.round(Math.abs(delta) / step) * Math.sign(delta);
|
|
25
|
+
const precision = Math.min(15, Math.max(decimals(start), decimals(step)));
|
|
26
|
+
return Number((start + steps * step).toFixed(precision));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseRangeDraft(
|
|
30
|
+
draft: string,
|
|
31
|
+
parseValue: (value: string) => number | undefined,
|
|
32
|
+
): ParsedRangeDraft {
|
|
33
|
+
if (!draft.trim()) return { kind: "empty" };
|
|
34
|
+
|
|
35
|
+
const parsed = parseValue(draft);
|
|
36
|
+
if (parsed === undefined || !Number.isFinite(parsed)) return { kind: "invalid" };
|
|
37
|
+
|
|
38
|
+
return { kind: "value", value: parsed };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeRangeValue(value: RangeValue, min: number, max: number): RangeValue {
|
|
42
|
+
const from =
|
|
43
|
+
typeof value.from === "number" && Number.isFinite(value.from)
|
|
44
|
+
? clampRangeValue(value.from, min, max)
|
|
45
|
+
: undefined;
|
|
46
|
+
const to =
|
|
47
|
+
typeof value.to === "number" && Number.isFinite(value.to)
|
|
48
|
+
? clampRangeValue(value.to, min, max)
|
|
49
|
+
: undefined;
|
|
50
|
+
|
|
51
|
+
if (from !== undefined && to !== undefined && from > to) {
|
|
52
|
+
return { from: to, to: from };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { from, to };
|
|
56
|
+
}
|
|
@@ -21,7 +21,8 @@ export interface StatCardProps extends Omit<ViewProps, "style" | "children"> {
|
|
|
21
21
|
className?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
/** Default matches Figma users-alt vector width (W 25.63181). */
|
|
25
|
+
const ICON_SIZE = 25.63181;
|
|
25
26
|
|
|
26
27
|
export const StatCard = forwardRef<ComponentRef<typeof View>, StatCardProps>(function StatCard(
|
|
27
28
|
{
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Svg, { Path } from "react-native-svg";
|
|
2
|
+
import { colors } from "../../theme";
|
|
3
|
+
import type { IconProps } from "../types";
|
|
4
|
+
import { CLOSE_PATH } from "./closePath";
|
|
5
|
+
|
|
6
|
+
export function CloseIcon({ size = 20, color = colors.white, strokeWidth = 2 }: IconProps) {
|
|
7
|
+
return (
|
|
8
|
+
<Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
|
|
9
|
+
<Path
|
|
10
|
+
d={CLOSE_PATH}
|
|
11
|
+
stroke={color}
|
|
12
|
+
strokeWidth={strokeWidth}
|
|
13
|
+
strokeLinecap="round"
|
|
14
|
+
strokeLinejoin="round"
|
|
15
|
+
/>
|
|
16
|
+
</Svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { colors } from "../../theme";
|
|
2
|
+
import type { IconProps } from "../types";
|
|
3
|
+
import { CLOSE_PATH } from "./closePath";
|
|
4
|
+
|
|
5
|
+
export function CloseIcon({ size = 20, color = colors.white, strokeWidth = 2 }: IconProps) {
|
|
6
|
+
return (
|
|
7
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
8
|
+
<path
|
|
9
|
+
d={CLOSE_PATH}
|
|
10
|
+
stroke={color}
|
|
11
|
+
strokeWidth={strokeWidth}
|
|
12
|
+
strokeLinecap="round"
|
|
13
|
+
strokeLinejoin="round"
|
|
14
|
+
/>
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -12,11 +12,18 @@ import {
|
|
|
12
12
|
SAVE_HEART_PATH,
|
|
13
13
|
} from "./cottageCardPaths";
|
|
14
14
|
|
|
15
|
-
export function SaveHeartIcon({
|
|
15
|
+
export function SaveHeartIcon({
|
|
16
|
+
size = 16,
|
|
17
|
+
color = colors.white,
|
|
18
|
+
strokeWidth = 2,
|
|
19
|
+
fillOpacity = 0,
|
|
20
|
+
}: IconProps) {
|
|
16
21
|
return (
|
|
17
22
|
<Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
|
|
18
23
|
<Path
|
|
19
24
|
d={SAVE_HEART_PATH}
|
|
25
|
+
fill={color}
|
|
26
|
+
fillOpacity={fillOpacity}
|
|
20
27
|
stroke={color}
|
|
21
28
|
strokeWidth={strokeWidth}
|
|
22
29
|
strokeLinecap="round"
|
|
@@ -11,11 +11,18 @@ import {
|
|
|
11
11
|
SAVE_HEART_PATH,
|
|
12
12
|
} from "./cottageCardPaths";
|
|
13
13
|
|
|
14
|
-
export function SaveHeartIcon({
|
|
14
|
+
export function SaveHeartIcon({
|
|
15
|
+
size = 16,
|
|
16
|
+
color = colors.white,
|
|
17
|
+
strokeWidth = 2,
|
|
18
|
+
fillOpacity = 0,
|
|
19
|
+
}: IconProps) {
|
|
15
20
|
return (
|
|
16
21
|
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
17
22
|
<path
|
|
18
23
|
d={SAVE_HEART_PATH}
|
|
24
|
+
fill={color}
|
|
25
|
+
fillOpacity={fillOpacity}
|
|
19
26
|
stroke={color}
|
|
20
27
|
strokeWidth={strokeWidth}
|
|
21
28
|
strokeLinecap="round"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import Svg, { Path } from "react-native-svg";
|
|
2
|
+
import { colors } from "../../theme";
|
|
3
|
+
import type { IconProps } from "../types";
|
|
4
|
+
import { MAP_COLLAPSE_PATH, MAP_EXPAND_PATH } from "./mapControlPaths";
|
|
5
|
+
|
|
6
|
+
function MapControlIcon({
|
|
7
|
+
path,
|
|
8
|
+
size = 20,
|
|
9
|
+
color = colors.white,
|
|
10
|
+
strokeWidth = 2,
|
|
11
|
+
}: IconProps & { path: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
|
|
14
|
+
<Path
|
|
15
|
+
d={path}
|
|
16
|
+
stroke={color}
|
|
17
|
+
strokeWidth={strokeWidth}
|
|
18
|
+
strokeLinecap="round"
|
|
19
|
+
strokeLinejoin="round"
|
|
20
|
+
/>
|
|
21
|
+
</Svg>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function MapExpandIcon(props: IconProps) {
|
|
26
|
+
return <MapControlIcon {...props} path={MAP_EXPAND_PATH} />;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function MapCollapseIcon(props: IconProps) {
|
|
30
|
+
return <MapControlIcon {...props} path={MAP_COLLAPSE_PATH} />;
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { colors } from "../../theme";
|
|
2
|
+
import type { IconProps } from "../types";
|
|
3
|
+
import { MAP_COLLAPSE_PATH, MAP_EXPAND_PATH } from "./mapControlPaths";
|
|
4
|
+
|
|
5
|
+
function MapControlIcon({
|
|
6
|
+
path,
|
|
7
|
+
size = 20,
|
|
8
|
+
color = colors.white,
|
|
9
|
+
strokeWidth = 2,
|
|
10
|
+
}: IconProps & { path: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<svg width={size} height={size} viewBox="0 0 20 20" fill="none" aria-hidden>
|
|
13
|
+
<path
|
|
14
|
+
d={path}
|
|
15
|
+
stroke={color}
|
|
16
|
+
strokeWidth={strokeWidth}
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
strokeLinejoin="round"
|
|
19
|
+
/>
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function MapExpandIcon(props: IconProps) {
|
|
25
|
+
return <MapControlIcon {...props} path={MAP_EXPAND_PATH} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function MapCollapseIcon(props: IconProps) {
|
|
29
|
+
return <MapControlIcon {...props} path={MAP_COLLAPSE_PATH} />;
|
|
30
|
+
}
|
package/src/icons/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export { CheckBadgeIcon } from "./CheckBadge";
|
|
|
17
17
|
export { ChevronDownIcon } from "./ChevronDown";
|
|
18
18
|
export { ChevronLeftIcon } from "./ChevronLeft";
|
|
19
19
|
export { ClockIcon } from "./Clock";
|
|
20
|
+
export { CloseIcon } from "./Close";
|
|
20
21
|
export {
|
|
21
22
|
BathroomsIcon,
|
|
22
23
|
BedroomsIcon,
|
|
@@ -50,6 +51,7 @@ export { LocationPinIcon } from "./LocationPin";
|
|
|
50
51
|
export { LogOutIcon } from "./LogOut";
|
|
51
52
|
export { MailIcon } from "./Mail";
|
|
52
53
|
export { MapViewIcon } from "./MapView";
|
|
54
|
+
export { MapCollapseIcon, MapExpandIcon } from "./MapControls";
|
|
53
55
|
export { MenuIcon } from "./Menu";
|
|
54
56
|
export type { MenuIconProps } from "./Menu";
|
|
55
57
|
export { NavigateIcon } from "./Navigate";
|
package/src/icons/registry.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { CheckBadgeIcon } from "./CheckBadge";
|
|
|
10
10
|
import { ChevronDownIcon } from "./ChevronDown";
|
|
11
11
|
import { ChevronLeftIcon } from "./ChevronLeft";
|
|
12
12
|
import { ClockIcon } from "./Clock";
|
|
13
|
+
import { CloseIcon } from "./Close";
|
|
13
14
|
import {
|
|
14
15
|
BathroomsIcon,
|
|
15
16
|
BedroomsIcon,
|
|
@@ -43,6 +44,7 @@ import { LocationPinIcon } from "./LocationPin";
|
|
|
43
44
|
import { LogOutIcon } from "./LogOut";
|
|
44
45
|
import { MailIcon } from "./Mail";
|
|
45
46
|
import { MapViewIcon } from "./MapView";
|
|
47
|
+
import { MapCollapseIcon, MapExpandIcon } from "./MapControls";
|
|
46
48
|
import { MenuIcon } from "./Menu";
|
|
47
49
|
import { MinusIcon } from "./Minus";
|
|
48
50
|
import { NavigateIcon } from "./Navigate";
|
|
@@ -75,6 +77,7 @@ export const icons = {
|
|
|
75
77
|
chevronDown: ChevronDownIcon,
|
|
76
78
|
chevronLeft: ChevronLeftIcon,
|
|
77
79
|
clock: ClockIcon,
|
|
80
|
+
close: CloseIcon,
|
|
78
81
|
clockFilled: ClockFilledIcon,
|
|
79
82
|
copy: CopyIcon,
|
|
80
83
|
bathrooms: BathroomsIcon,
|
|
@@ -96,6 +99,8 @@ export const icons = {
|
|
|
96
99
|
logOut: LogOutIcon,
|
|
97
100
|
mail: MailIcon,
|
|
98
101
|
mapView: MapViewIcon,
|
|
102
|
+
mapCollapse: MapCollapseIcon,
|
|
103
|
+
mapExpand: MapExpandIcon,
|
|
99
104
|
menu: MenuIcon,
|
|
100
105
|
minus: MinusIcon,
|
|
101
106
|
navigate: NavigateIcon,
|
|
@@ -131,6 +136,8 @@ export const iconGroups = {
|
|
|
131
136
|
"home",
|
|
132
137
|
"listView",
|
|
133
138
|
"locationPin",
|
|
139
|
+
"mapCollapse",
|
|
140
|
+
"mapExpand",
|
|
134
141
|
"mapView",
|
|
135
142
|
"menu",
|
|
136
143
|
"navigate",
|
|
@@ -147,6 +154,7 @@ export const iconGroups = {
|
|
|
147
154
|
"search",
|
|
148
155
|
"calendar",
|
|
149
156
|
"calendarOutline",
|
|
157
|
+
"close",
|
|
150
158
|
"ratingStar",
|
|
151
159
|
"saveHeart",
|
|
152
160
|
"share",
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export {
|
|
|
15
15
|
Toggle,
|
|
16
16
|
RatingInput,
|
|
17
17
|
CommentCard,
|
|
18
|
+
Range,
|
|
18
19
|
} from "./components";
|
|
19
20
|
export type {
|
|
20
21
|
AvatarProps,
|
|
@@ -48,6 +49,8 @@ export type {
|
|
|
48
49
|
ToggleProps,
|
|
49
50
|
RatingInputProps,
|
|
50
51
|
CommentCardProps,
|
|
52
|
+
RangeProps,
|
|
53
|
+
RangeValue,
|
|
51
54
|
} from "./components";
|
|
52
55
|
|
|
53
56
|
export {
|
|
@@ -55,9 +58,15 @@ export {
|
|
|
55
58
|
BRAND_LOGO_COLORS,
|
|
56
59
|
BRAND_LOGO_DEFAULT_HEIGHT,
|
|
57
60
|
BRAND_LOGO_DEFAULT_WIDTH,
|
|
61
|
+
FavoritesButton,
|
|
58
62
|
MenuItem,
|
|
59
63
|
} from "./primitives";
|
|
60
|
-
export type {
|
|
64
|
+
export type {
|
|
65
|
+
BrandLogoProps,
|
|
66
|
+
BrandLogoVariant,
|
|
67
|
+
FavoritesButtonProps,
|
|
68
|
+
MenuItemProps,
|
|
69
|
+
} from "./primitives";
|
|
61
70
|
|
|
62
71
|
export {
|
|
63
72
|
Icon,
|
|
@@ -78,6 +87,7 @@ export {
|
|
|
78
87
|
ChevronDownIcon,
|
|
79
88
|
ChevronLeftIcon,
|
|
80
89
|
ClockIcon,
|
|
90
|
+
CloseIcon,
|
|
81
91
|
BathroomsIcon,
|
|
82
92
|
BedroomsIcon,
|
|
83
93
|
CardLocationIcon,
|
|
@@ -107,6 +117,8 @@ export {
|
|
|
107
117
|
LogOutIcon,
|
|
108
118
|
MailIcon,
|
|
109
119
|
MapViewIcon,
|
|
120
|
+
MapCollapseIcon,
|
|
121
|
+
MapExpandIcon,
|
|
110
122
|
MenuIcon,
|
|
111
123
|
MinusIcon,
|
|
112
124
|
NavigateIcon,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { fn } from "storybook/test";
|
|
3
|
+
import { StyleSheet, View } from "react-native";
|
|
4
|
+
import { FavoritesButton } from "./FavoritesButton";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Primitives/FavoritesButton",
|
|
8
|
+
component: FavoritesButton,
|
|
9
|
+
args: {
|
|
10
|
+
onValueChange: fn(),
|
|
11
|
+
},
|
|
12
|
+
argTypes: {
|
|
13
|
+
value: { control: "boolean" },
|
|
14
|
+
defaultValue: { control: "boolean" },
|
|
15
|
+
disabled: { control: "boolean" },
|
|
16
|
+
onValueChange: { action: "value changed" },
|
|
17
|
+
},
|
|
18
|
+
parameters: {
|
|
19
|
+
backgrounds: { default: "black" },
|
|
20
|
+
},
|
|
21
|
+
decorators: [
|
|
22
|
+
(Story) => (
|
|
23
|
+
<View style={styles.frame}>
|
|
24
|
+
<Story />
|
|
25
|
+
</View>
|
|
26
|
+
),
|
|
27
|
+
],
|
|
28
|
+
} satisfies Meta<typeof FavoritesButton>;
|
|
29
|
+
|
|
30
|
+
export default meta;
|
|
31
|
+
type Story = StoryObj<typeof meta>;
|
|
32
|
+
|
|
33
|
+
export const Default: Story = {};
|
|
34
|
+
|
|
35
|
+
export const Active: Story = {
|
|
36
|
+
args: { value: true },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const Disabled: Story = {
|
|
40
|
+
args: { disabled: true },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const AllStates: Story = {
|
|
44
|
+
render: () => (
|
|
45
|
+
<View style={styles.row}>
|
|
46
|
+
<FavoritesButton accessibilityLabel="Add to favorites" onValueChange={fn()} value={false} />
|
|
47
|
+
<FavoritesButton accessibilityLabel="Remove from favorites" onValueChange={fn()} value />
|
|
48
|
+
</View>
|
|
49
|
+
),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const styles = StyleSheet.create({
|
|
53
|
+
frame: {
|
|
54
|
+
padding: 24,
|
|
55
|
+
},
|
|
56
|
+
row: {
|
|
57
|
+
flexDirection: "row",
|
|
58
|
+
gap: 16,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { forwardRef, useMemo, useRef, useState, type ComponentRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
type PressableProps,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from "react-native";
|
|
10
|
+
import { SaveHeartIcon } from "../../icons";
|
|
11
|
+
import { colors } from "../../theme";
|
|
12
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
13
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
14
|
+
|
|
15
|
+
export interface FavoritesButtonProps extends Omit<
|
|
16
|
+
PressableProps,
|
|
17
|
+
"onPress" | "disabled" | "style" | "children" | "hitSlop"
|
|
18
|
+
> {
|
|
19
|
+
/** Controlled favorite state. */
|
|
20
|
+
value?: boolean;
|
|
21
|
+
/** Initial state when the button is uncontrolled. */
|
|
22
|
+
defaultValue?: boolean;
|
|
23
|
+
/** Called with the next favorite state when pressed. */
|
|
24
|
+
onValueChange?: (value: boolean) => void;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
className?: string;
|
|
28
|
+
hitSlop?: PressableProps["hitSlop"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ICON_SIZE = 16;
|
|
32
|
+
const BACKGROUND_COLOR = "#09090966";
|
|
33
|
+
|
|
34
|
+
const webBlurStyle: ViewStyle =
|
|
35
|
+
Platform.OS === "web"
|
|
36
|
+
? ({
|
|
37
|
+
backdropFilter: "blur(10px)",
|
|
38
|
+
WebkitBackdropFilter: "blur(10px)",
|
|
39
|
+
} as ViewStyle)
|
|
40
|
+
: {};
|
|
41
|
+
|
|
42
|
+
export const FavoritesButton = forwardRef<ComponentRef<typeof Pressable>, FavoritesButtonProps>(
|
|
43
|
+
function FavoritesButton(
|
|
44
|
+
{
|
|
45
|
+
value,
|
|
46
|
+
defaultValue = false,
|
|
47
|
+
onValueChange,
|
|
48
|
+
disabled = false,
|
|
49
|
+
style,
|
|
50
|
+
className,
|
|
51
|
+
accessibilityLabel,
|
|
52
|
+
hitSlop = 8,
|
|
53
|
+
...rest
|
|
54
|
+
},
|
|
55
|
+
forwardedRef,
|
|
56
|
+
) {
|
|
57
|
+
const buttonRef = useRef<ComponentRef<typeof Pressable>>(null);
|
|
58
|
+
const isControlled = typeof value === "boolean";
|
|
59
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue);
|
|
60
|
+
const isActive = isControlled ? value : uncontrolledValue;
|
|
61
|
+
const setButtonRef = useMemo(() => mergeRefs(buttonRef, forwardedRef), [forwardedRef]);
|
|
62
|
+
|
|
63
|
+
useApplyWebClassName(buttonRef, className?.trim() || undefined);
|
|
64
|
+
|
|
65
|
+
const handlePress = () => {
|
|
66
|
+
const nextValue = !isActive;
|
|
67
|
+
if (!isControlled) setUncontrolledValue(nextValue);
|
|
68
|
+
onValueChange?.(nextValue);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Pressable
|
|
73
|
+
{...rest}
|
|
74
|
+
ref={setButtonRef}
|
|
75
|
+
accessibilityLabel={
|
|
76
|
+
accessibilityLabel ?? (isActive ? "Remove from favorites" : "Add to favorites")
|
|
77
|
+
}
|
|
78
|
+
accessibilityRole="togglebutton"
|
|
79
|
+
accessibilityState={{ checked: isActive, disabled }}
|
|
80
|
+
disabled={disabled}
|
|
81
|
+
hitSlop={hitSlop}
|
|
82
|
+
onPress={handlePress}
|
|
83
|
+
style={({ pressed }) => [
|
|
84
|
+
styles.button,
|
|
85
|
+
webBlurStyle,
|
|
86
|
+
pressed && styles.pressed,
|
|
87
|
+
disabled && styles.disabled,
|
|
88
|
+
style,
|
|
89
|
+
]}
|
|
90
|
+
>
|
|
91
|
+
<SaveHeartIcon
|
|
92
|
+
color={isActive ? colors.red : colors.white}
|
|
93
|
+
fillOpacity={isActive ? 1 : 0}
|
|
94
|
+
size={ICON_SIZE}
|
|
95
|
+
strokeWidth={2}
|
|
96
|
+
/>
|
|
97
|
+
</Pressable>
|
|
98
|
+
);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const styles = StyleSheet.create({
|
|
103
|
+
button: {
|
|
104
|
+
padding: 12,
|
|
105
|
+
gap: 10,
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "center",
|
|
108
|
+
alignSelf: "flex-start",
|
|
109
|
+
borderRadius: 39,
|
|
110
|
+
borderWidth: 1,
|
|
111
|
+
borderColor: colors.whiteAlpha10,
|
|
112
|
+
backgroundColor: BACKGROUND_COLOR,
|
|
113
|
+
},
|
|
114
|
+
pressed: {
|
|
115
|
+
opacity: 0.8,
|
|
116
|
+
},
|
|
117
|
+
disabled: {
|
|
118
|
+
opacity: 0.5,
|
|
119
|
+
},
|
|
120
|
+
});
|
package/src/primitives/index.ts
CHANGED