@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export { Range } from "./Range";
2
+ export type { RangeProps, RangeValue } from "./Range.types";
@@ -21,7 +21,8 @@ export interface StatCardProps extends Omit<ViewProps, "style" | "children"> {
21
21
  className?: string;
22
22
  }
23
23
 
24
- const ICON_SIZE = 28;
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
  {
@@ -53,3 +53,6 @@ export type { RatingInputProps } from "./RatingInput";
53
53
 
54
54
  export { CommentCard } from "./CommentCard";
55
55
  export type { CommentCardProps } from "./CommentCard";
56
+
57
+ export { Range } from "./Range";
58
+ export type { RangeProps, RangeValue } from "./Range";
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** Close (x) path — 20×20 viewBox. */
2
+ export const CLOSE_PATH = "M15 5L5 15M5 5L15 15";
@@ -0,0 +1,2 @@
1
+ export { CloseIcon } from "./CloseIcon";
2
+ export { CLOSE_PATH } from "./closePath";
@@ -12,11 +12,18 @@ import {
12
12
  SAVE_HEART_PATH,
13
13
  } from "./cottageCardPaths";
14
14
 
15
- export function SaveHeartIcon({ size = 16, color = colors.white, strokeWidth = 2 }: IconProps) {
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({ size = 16, color = colors.white, strokeWidth = 2 }: IconProps) {
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
+ }
@@ -0,0 +1,2 @@
1
+ export { MapCollapseIcon, MapExpandIcon } from "./MapControlIcons";
2
+ export { MAP_COLLAPSE_PATH, MAP_EXPAND_PATH } from "./mapControlPaths";
@@ -0,0 +1,4 @@
1
+ /** Map control paths — 20×20 viewBox. */
2
+ export const MAP_EXPAND_PATH = "M8.33366 15.8332H4.16699V11.6665M11.667 4.1665H15.8337V8.33317";
3
+
4
+ export const MAP_COLLAPSE_PATH = "M4.16699 11.6665H8.33366V15.8332M15.8337 8.33317H11.667V4.1665";
@@ -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";
@@ -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 { BrandLogoProps, BrandLogoVariant, MenuItemProps } from "./primitives";
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
+ });
@@ -0,0 +1,2 @@
1
+ export { FavoritesButton } from "./FavoritesButton";
2
+ export type { FavoritesButtonProps } from "./FavoritesButton";
@@ -1,5 +1,7 @@
1
1
  export { MenuItem } from "./MenuItem";
2
2
  export type { MenuItemProps } from "./MenuItem";
3
+ export { FavoritesButton } from "./FavoritesButton";
4
+ export type { FavoritesButtonProps } from "./FavoritesButton";
3
5
 
4
6
  export {
5
7
  BrandLogo,