@ecohouse/ui 0.1.4 → 0.1.6
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 +66 -15
- package/dist/index.cjs +1293 -147
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +358 -154
- package/dist/index.d.ts +358 -154
- package/dist/index.js +1278 -149
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/Avatar.tsx +19 -71
- package/src/components/Badge/Badge.stories.tsx +52 -0
- package/src/components/Badge/Badge.tsx +131 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/Counter/Counter.stories.tsx +46 -0
- package/src/components/Counter/Counter.tsx +135 -0
- package/src/components/Counter/index.ts +2 -0
- package/src/components/DatePicker/DatePicker.stories.tsx +172 -0
- package/src/components/DatePicker/DatePicker.tsx +628 -0
- package/src/components/DatePicker/index.ts +7 -0
- package/src/components/SegmentedToggle/SegmentedToggle.stories.tsx +63 -0
- package/src/components/SegmentedToggle/SegmentedToggle.tsx +221 -0
- package/src/components/SegmentedToggle/index.ts +2 -0
- package/src/components/StatCard/StatCard.stories.tsx +20 -0
- package/src/components/StatCard/StatCard.tsx +61 -0
- package/src/components/StatCard/index.ts +2 -0
- package/src/components/index.ts +20 -0
- package/src/icons/CalendarOutline/CalendarOutlineIcon.tsx +25 -0
- package/src/icons/CalendarOutline/CalendarOutlineIcon.web.tsx +31 -0
- package/src/icons/CalendarOutline/calendarOutlinePath.ts +3 -0
- package/src/icons/CalendarOutline/index.ts +1 -0
- package/src/icons/ChevronLeft/ChevronLeftIcon.tsx +21 -0
- package/src/icons/ChevronLeft/ChevronLeftIcon.web.tsx +27 -0
- package/src/icons/ChevronLeft/chevronLeftPath.ts +2 -0
- package/src/icons/ChevronLeft/index.ts +1 -0
- package/src/icons/Minus/MinusIcon.tsx +20 -0
- package/src/icons/Minus/MinusIcon.web.tsx +19 -0
- package/src/icons/Minus/index.ts +1 -0
- package/src/icons/Minus/minusPath.ts +1 -0
- package/src/icons/Plus/PlusIcon.tsx +20 -0
- package/src/icons/Plus/PlusIcon.web.tsx +19 -0
- package/src/icons/Plus/index.ts +1 -0
- package/src/icons/Plus/plusPath.ts +1 -0
- package/src/icons/Sidebar/SidebarIcon.tsx +21 -0
- package/src/icons/Sidebar/SidebarIcon.web.tsx +27 -0
- package/src/icons/Sidebar/index.ts +1 -0
- package/src/icons/Sidebar/sidebarPath.ts +3 -0
- package/src/icons/UsersAlt/UsersAltIcon.tsx +14 -0
- package/src/icons/UsersAlt/UsersAltIcon.web.tsx +13 -0
- package/src/icons/UsersAlt/index.ts +1 -0
- package/src/icons/UsersAlt/usersAltPath.ts +2 -0
- package/src/icons/index.ts +6 -0
- package/src/icons/registry.ts +29 -4
- package/src/index.ts +38 -1
- package/src/primitives/MenuItem/MenuItem.stories.tsx +98 -0
- package/src/primitives/MenuItem/MenuItem.tsx +138 -0
- package/src/primitives/MenuItem/index.ts +2 -0
- package/src/primitives/index.ts +2 -0
- package/src/theme/colors.test.ts +6 -0
- package/src/theme/colors.ts +7 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/typography.ts +76 -0
- package/src/utils/dateUtils.ts +86 -0
package/package.json
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
useRef,
|
|
7
7
|
useState,
|
|
8
8
|
type ComponentRef,
|
|
9
|
-
type ReactNode,
|
|
10
9
|
} from "react";
|
|
11
10
|
import {
|
|
12
11
|
Image,
|
|
@@ -24,20 +23,17 @@ import { ChevronDownIcon, UserIcon } from "../../icons";
|
|
|
24
23
|
import { avatarTypography, colors } from "../../theme";
|
|
25
24
|
import { mergeRefs } from "../../utils/mergeRefs";
|
|
26
25
|
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
26
|
+
import { MenuItem, type MenuItemProps } from "../../primitives";
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/** Data shape for an Avatar dropdown option — same fields as `MenuItem`. */
|
|
29
|
+
export type AvatarMenuItem = Pick<
|
|
30
|
+
MenuItemProps,
|
|
31
|
+
"icon" | "label" | "disabled" | "backgroundColor" | "hoverColor"
|
|
32
|
+
> & {
|
|
33
|
+
/** Stable identity for React keys; falls back to `label`. */
|
|
30
34
|
key?: string;
|
|
31
|
-
/** Leading icon, e.g. `<CalendarIcon />` — pass it pre-colored. */
|
|
32
|
-
icon?: ReactNode;
|
|
33
|
-
label: string;
|
|
34
35
|
onPress: () => void;
|
|
35
|
-
|
|
36
|
-
/** Row background at rest. Defaults to transparent. */
|
|
37
|
-
backgroundColor?: string;
|
|
38
|
-
/** Row background on hover/press. Defaults to `colors.grey600`. */
|
|
39
|
-
hoverColor?: string;
|
|
40
|
-
}
|
|
36
|
+
};
|
|
41
37
|
|
|
42
38
|
export interface AvatarProps extends Omit<
|
|
43
39
|
PressableProps,
|
|
@@ -58,7 +54,7 @@ export interface AvatarProps extends Omit<
|
|
|
58
54
|
/**
|
|
59
55
|
* Account-menu options, supplied entirely by the consumer (icon, label,
|
|
60
56
|
* action, and per-item colors). When set, pressing the chip opens a
|
|
61
|
-
* dropdown listing them
|
|
57
|
+
* dropdown listing them via `MenuItem`.
|
|
62
58
|
*/
|
|
63
59
|
menuItems?: AvatarMenuItem[];
|
|
64
60
|
/** Controlled open state for the menu. */
|
|
@@ -77,8 +73,6 @@ const CONTAINER_RADIUS = 71;
|
|
|
77
73
|
const CHEVRON_SIZE = 20;
|
|
78
74
|
const FALLBACK_ICON_SIZE = 20;
|
|
79
75
|
const MENU_RADIUS = 16;
|
|
80
|
-
const MENU_ITEM_RADIUS = 12;
|
|
81
|
-
const MENU_ITEM_ICON_SIZE = 16;
|
|
82
76
|
|
|
83
77
|
export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(function Avatar(
|
|
84
78
|
{
|
|
@@ -105,9 +99,9 @@ export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(functio
|
|
|
105
99
|
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
106
100
|
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
107
101
|
const [imageFailed, setImageFailed] = useState(false);
|
|
108
|
-
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
|
109
102
|
|
|
110
|
-
const
|
|
103
|
+
const resolvedMenuItems = menuItems ?? [];
|
|
104
|
+
const hasMenu = resolvedMenuItems.length > 0;
|
|
111
105
|
const isInteractive = onPress != null || hasMenu;
|
|
112
106
|
const isOpenControlled = typeof openProp === "boolean";
|
|
113
107
|
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
|
@@ -119,7 +113,6 @@ export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(functio
|
|
|
119
113
|
(next: boolean) => {
|
|
120
114
|
if (!isOpenControlled) setUncontrolledOpen(next);
|
|
121
115
|
onOpenChange?.(next);
|
|
122
|
-
if (!next) setHoveredKey(null);
|
|
123
116
|
},
|
|
124
117
|
[isOpenControlled, onOpenChange],
|
|
125
118
|
);
|
|
@@ -237,39 +230,18 @@ export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(functio
|
|
|
237
230
|
|
|
238
231
|
{hasMenu && isOpen ? (
|
|
239
232
|
<View style={[styles.menu, menuWidth != null && { right: undefined, width: menuWidth }]}>
|
|
240
|
-
{
|
|
233
|
+
{resolvedMenuItems.map((item) => {
|
|
241
234
|
const itemKey = item.key ?? item.label;
|
|
242
|
-
const isHovered = hoveredKey === itemKey;
|
|
243
|
-
const backgroundColor = isHovered
|
|
244
|
-
? (item.hoverColor ?? colors.grey600)
|
|
245
|
-
: (item.backgroundColor ?? "transparent");
|
|
246
|
-
|
|
247
235
|
return (
|
|
248
|
-
<
|
|
236
|
+
<MenuItem
|
|
249
237
|
key={itemKey}
|
|
250
|
-
|
|
238
|
+
icon={item.icon}
|
|
239
|
+
label={item.label}
|
|
251
240
|
disabled={item.disabled}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
onPressIn={() => setHoveredKey(itemKey)}
|
|
257
|
-
onPressOut={() =>
|
|
258
|
-
setHoveredKey((current) => (current === itemKey ? null : current))
|
|
259
|
-
}
|
|
260
|
-
accessibilityRole="menuitem"
|
|
261
|
-
accessibilityState={{ disabled: item.disabled }}
|
|
262
|
-
style={[
|
|
263
|
-
styles.menuItem,
|
|
264
|
-
{ backgroundColor },
|
|
265
|
-
item.disabled && styles.menuItemDisabled,
|
|
266
|
-
]}
|
|
267
|
-
>
|
|
268
|
-
{item.icon ? <View style={styles.menuItemIcon}>{item.icon}</View> : null}
|
|
269
|
-
<Text style={styles.menuItemLabel} numberOfLines={1}>
|
|
270
|
-
{item.label}
|
|
271
|
-
</Text>
|
|
272
|
-
</Pressable>
|
|
241
|
+
backgroundColor={item.backgroundColor}
|
|
242
|
+
hoverColor={item.hoverColor}
|
|
243
|
+
onPress={() => handleItemPress(item)}
|
|
244
|
+
/>
|
|
273
245
|
);
|
|
274
246
|
})}
|
|
275
247
|
</View>
|
|
@@ -362,28 +334,4 @@ const styles = StyleSheet.create({
|
|
|
362
334
|
},
|
|
363
335
|
}),
|
|
364
336
|
},
|
|
365
|
-
menuItem: {
|
|
366
|
-
minHeight: 40,
|
|
367
|
-
borderRadius: MENU_ITEM_RADIUS,
|
|
368
|
-
paddingVertical: 12,
|
|
369
|
-
paddingHorizontal: 12,
|
|
370
|
-
flexDirection: "row",
|
|
371
|
-
alignItems: "center",
|
|
372
|
-
gap: 8,
|
|
373
|
-
},
|
|
374
|
-
menuItemDisabled: {
|
|
375
|
-
opacity: 0.5,
|
|
376
|
-
},
|
|
377
|
-
menuItemIcon: {
|
|
378
|
-
width: MENU_ITEM_ICON_SIZE,
|
|
379
|
-
height: MENU_ITEM_ICON_SIZE,
|
|
380
|
-
alignItems: "center",
|
|
381
|
-
justifyContent: "center",
|
|
382
|
-
flexShrink: 0,
|
|
383
|
-
},
|
|
384
|
-
menuItemLabel: {
|
|
385
|
-
...avatarTypography.menuItem,
|
|
386
|
-
color: colors.white,
|
|
387
|
-
flex: 1,
|
|
388
|
-
},
|
|
389
337
|
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { View, StyleSheet } from "react-native";
|
|
3
|
+
import { HomeIcon } from "../../icons";
|
|
4
|
+
import { Badge } from "./Badge";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Components/Badge",
|
|
8
|
+
component: Badge,
|
|
9
|
+
args: {
|
|
10
|
+
label: "Label",
|
|
11
|
+
variant: "default",
|
|
12
|
+
icon: HomeIcon,
|
|
13
|
+
},
|
|
14
|
+
argTypes: {
|
|
15
|
+
variant: {
|
|
16
|
+
control: "select",
|
|
17
|
+
options: ["default", "transparent"],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
parameters: {
|
|
21
|
+
backgrounds: { default: "black" },
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof Badge>;
|
|
24
|
+
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
export const Default: Story = {};
|
|
29
|
+
|
|
30
|
+
export const Transparent: Story = {
|
|
31
|
+
args: { variant: "transparent" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const WithNoIcon: Story = {
|
|
35
|
+
args: { icon: undefined },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const AllVariants: Story = {
|
|
39
|
+
render: () => (
|
|
40
|
+
<View style={storyStyles.column}>
|
|
41
|
+
<Badge label="Label" icon={HomeIcon} variant="default" />
|
|
42
|
+
<Badge label="Label" icon={HomeIcon} variant="transparent" />
|
|
43
|
+
</View>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const storyStyles = StyleSheet.create({
|
|
48
|
+
column: {
|
|
49
|
+
gap: 12,
|
|
50
|
+
alignItems: "flex-start",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { forwardRef, useMemo, useRef, type ComponentRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
Text,
|
|
6
|
+
View,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewProps,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from "react-native";
|
|
11
|
+
import type { IconComponent } from "../../icons";
|
|
12
|
+
import { badgeTypography, colors } from "../../theme";
|
|
13
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
14
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
15
|
+
|
|
16
|
+
export type BadgeVariant = "default" | "transparent";
|
|
17
|
+
|
|
18
|
+
export interface BadgeProps extends Omit<ViewProps, "style" | "children"> {
|
|
19
|
+
label: string;
|
|
20
|
+
variant?: BadgeVariant;
|
|
21
|
+
icon?: IconComponent;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ICON_SIZE = 20;
|
|
27
|
+
|
|
28
|
+
const webBlurStyle: ViewStyle =
|
|
29
|
+
Platform.OS === "web"
|
|
30
|
+
? ({
|
|
31
|
+
backdropFilter: "blur(10.3px)",
|
|
32
|
+
WebkitBackdropFilter: "blur(10.3px)",
|
|
33
|
+
} as ViewStyle)
|
|
34
|
+
: {};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Variant chrome — icon color is part of the variant, not a separate prop.
|
|
38
|
+
* - default: grey fill, no border, white icon
|
|
39
|
+
* - transparent: muted white fill + 1px border, primary icon, blur on web
|
|
40
|
+
*/
|
|
41
|
+
const variantConfig = {
|
|
42
|
+
default: {
|
|
43
|
+
backgroundColor: colors.grey750,
|
|
44
|
+
iconColor: colors.white,
|
|
45
|
+
border: false,
|
|
46
|
+
blur: false,
|
|
47
|
+
},
|
|
48
|
+
transparent: {
|
|
49
|
+
backgroundColor: colors.whiteAlpha5,
|
|
50
|
+
iconColor: colors.primary,
|
|
51
|
+
border: true,
|
|
52
|
+
blur: true,
|
|
53
|
+
},
|
|
54
|
+
} as const satisfies Record<
|
|
55
|
+
BadgeVariant,
|
|
56
|
+
{ backgroundColor: string; iconColor: string; border: boolean; blur: boolean }
|
|
57
|
+
>;
|
|
58
|
+
|
|
59
|
+
export const Badge = forwardRef<ComponentRef<typeof View>, BadgeProps>(function Badge(
|
|
60
|
+
{ label, variant = "default", icon: Icon, style, className, accessibilityLabel, ...rest },
|
|
61
|
+
forwardedRef,
|
|
62
|
+
) {
|
|
63
|
+
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
64
|
+
const preset = variantConfig[variant];
|
|
65
|
+
const resolvedClassName = className?.trim() || undefined;
|
|
66
|
+
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
67
|
+
|
|
68
|
+
useApplyWebClassName(containerRef, resolvedClassName);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<View
|
|
72
|
+
{...rest}
|
|
73
|
+
ref={setContainerRef}
|
|
74
|
+
style={[
|
|
75
|
+
styles.base,
|
|
76
|
+
{ backgroundColor: preset.backgroundColor },
|
|
77
|
+
preset.border ? styles.transparentBorder : null,
|
|
78
|
+
preset.blur ? webBlurStyle : null,
|
|
79
|
+
style,
|
|
80
|
+
]}
|
|
81
|
+
accessibilityRole="text"
|
|
82
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
83
|
+
>
|
|
84
|
+
{Icon ? (
|
|
85
|
+
<View style={styles.iconSlot}>
|
|
86
|
+
<Icon size={ICON_SIZE} color={preset.iconColor} />
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
<Text style={[styles.label, Platform.OS === "android" ? styles.labelAndroid : null]}>
|
|
91
|
+
{label}
|
|
92
|
+
</Text>
|
|
93
|
+
</View>
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const styles = StyleSheet.create({
|
|
98
|
+
base: {
|
|
99
|
+
flexDirection: "row",
|
|
100
|
+
alignItems: "center",
|
|
101
|
+
alignSelf: "flex-start",
|
|
102
|
+
minHeight: 44,
|
|
103
|
+
paddingVertical: 12,
|
|
104
|
+
paddingHorizontal: 16,
|
|
105
|
+
gap: 12,
|
|
106
|
+
borderRadius: 79,
|
|
107
|
+
overflow: "hidden",
|
|
108
|
+
borderWidth: 0,
|
|
109
|
+
borderColor: "transparent",
|
|
110
|
+
borderStyle: "solid",
|
|
111
|
+
},
|
|
112
|
+
transparentBorder: {
|
|
113
|
+
borderWidth: 1,
|
|
114
|
+
borderColor: colors.whiteAlpha5,
|
|
115
|
+
borderStyle: "solid",
|
|
116
|
+
},
|
|
117
|
+
iconSlot: {
|
|
118
|
+
width: ICON_SIZE,
|
|
119
|
+
height: ICON_SIZE,
|
|
120
|
+
alignItems: "center",
|
|
121
|
+
justifyContent: "center",
|
|
122
|
+
flexShrink: 0,
|
|
123
|
+
},
|
|
124
|
+
label: {
|
|
125
|
+
...badgeTypography,
|
|
126
|
+
color: colors.white,
|
|
127
|
+
},
|
|
128
|
+
labelAndroid: {
|
|
129
|
+
includeFontPadding: false,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { Counter } from "./Counter";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Components/Counter",
|
|
7
|
+
component: Counter,
|
|
8
|
+
args: {
|
|
9
|
+
defaultValue: 1,
|
|
10
|
+
min: 1,
|
|
11
|
+
},
|
|
12
|
+
argTypes: {
|
|
13
|
+
value: { control: "number" },
|
|
14
|
+
defaultValue: { control: "number" },
|
|
15
|
+
min: { control: "number" },
|
|
16
|
+
max: { control: "number" },
|
|
17
|
+
step: { control: "number" },
|
|
18
|
+
disabled: { control: "boolean" },
|
|
19
|
+
onValueChange: { action: "valueChange" },
|
|
20
|
+
},
|
|
21
|
+
parameters: {
|
|
22
|
+
backgrounds: { default: "black" },
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof Counter>;
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
27
|
+
type Story = StoryObj<typeof meta>;
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {};
|
|
30
|
+
|
|
31
|
+
export const AtMaximum: Story = {
|
|
32
|
+
args: { defaultValue: 5, max: 5 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const Disabled: Story = {
|
|
36
|
+
args: { disabled: true },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function ControlledCounter() {
|
|
40
|
+
const [value, setValue] = useState(1);
|
|
41
|
+
return <Counter value={value} min={1} max={5} onValueChange={setValue} />;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Controlled: Story = {
|
|
45
|
+
render: () => <ControlledCounter />,
|
|
46
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { forwardRef, useMemo, useRef, useState, type ComponentRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
Platform,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
type PressableProps,
|
|
9
|
+
type StyleProp,
|
|
10
|
+
type ViewProps,
|
|
11
|
+
type ViewStyle,
|
|
12
|
+
} from "react-native";
|
|
13
|
+
import { MinusIcon, PlusIcon } from "../../icons";
|
|
14
|
+
import { colors, counterTypography } from "../../theme";
|
|
15
|
+
import { mergeRefs } from "../../utils/mergeRefs";
|
|
16
|
+
import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
|
|
17
|
+
|
|
18
|
+
export interface CounterProps extends Omit<ViewProps, "style" | "children"> {
|
|
19
|
+
value?: number;
|
|
20
|
+
defaultValue?: number;
|
|
21
|
+
onValueChange?: (value: number) => void;
|
|
22
|
+
min?: number;
|
|
23
|
+
max?: number;
|
|
24
|
+
step?: number;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
style?: StyleProp<ViewStyle>;
|
|
27
|
+
className?: string;
|
|
28
|
+
hitSlop?: PressableProps["hitSlop"];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const BUTTON_SIZE = 32;
|
|
32
|
+
|
|
33
|
+
const webBlurStyle: ViewStyle =
|
|
34
|
+
Platform.OS === "web"
|
|
35
|
+
? ({
|
|
36
|
+
backdropFilter: "blur(20px)",
|
|
37
|
+
WebkitBackdropFilter: "blur(20px)",
|
|
38
|
+
} as ViewStyle)
|
|
39
|
+
: {};
|
|
40
|
+
|
|
41
|
+
function clamp(value: number, min: number, max?: number) {
|
|
42
|
+
return Math.max(min, max === undefined ? value : Math.min(value, max));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Counter = forwardRef<ComponentRef<typeof View>, CounterProps>(function Counter(
|
|
46
|
+
{
|
|
47
|
+
value,
|
|
48
|
+
defaultValue,
|
|
49
|
+
onValueChange,
|
|
50
|
+
min = 1,
|
|
51
|
+
max,
|
|
52
|
+
step = 1,
|
|
53
|
+
disabled = false,
|
|
54
|
+
style,
|
|
55
|
+
className,
|
|
56
|
+
hitSlop,
|
|
57
|
+
...rest
|
|
58
|
+
},
|
|
59
|
+
forwardedRef,
|
|
60
|
+
) {
|
|
61
|
+
const containerRef = useRef<ComponentRef<typeof View>>(null);
|
|
62
|
+
const isControlled = value !== undefined;
|
|
63
|
+
const [uncontrolledValue, setUncontrolledValue] = useState(() =>
|
|
64
|
+
clamp(defaultValue ?? min, min, max),
|
|
65
|
+
);
|
|
66
|
+
const resolvedValue = clamp(isControlled ? value : uncontrolledValue, min, max);
|
|
67
|
+
const resolvedClassName = className?.trim() || undefined;
|
|
68
|
+
const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
|
|
69
|
+
const decrementDisabled = disabled || resolvedValue <= min;
|
|
70
|
+
const incrementDisabled = disabled || (max !== undefined && resolvedValue >= max);
|
|
71
|
+
|
|
72
|
+
useApplyWebClassName(containerRef, resolvedClassName);
|
|
73
|
+
|
|
74
|
+
const updateValue = (nextValue: number) => {
|
|
75
|
+
const next = clamp(nextValue, min, max);
|
|
76
|
+
if (next === resolvedValue) return;
|
|
77
|
+
if (!isControlled) setUncontrolledValue(next);
|
|
78
|
+
onValueChange?.(next);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<View {...rest} ref={setContainerRef} style={[styles.base, style]}>
|
|
83
|
+
<Pressable
|
|
84
|
+
disabled={decrementDisabled}
|
|
85
|
+
hitSlop={hitSlop}
|
|
86
|
+
onPress={() => updateValue(resolvedValue - step)}
|
|
87
|
+
accessibilityRole="button"
|
|
88
|
+
accessibilityLabel="Decrease value"
|
|
89
|
+
accessibilityState={{ disabled: decrementDisabled }}
|
|
90
|
+
style={[styles.button, webBlurStyle, decrementDisabled && styles.buttonDisabled]}
|
|
91
|
+
>
|
|
92
|
+
<MinusIcon />
|
|
93
|
+
</Pressable>
|
|
94
|
+
|
|
95
|
+
<Text style={styles.value}>{resolvedValue}</Text>
|
|
96
|
+
|
|
97
|
+
<Pressable
|
|
98
|
+
disabled={incrementDisabled}
|
|
99
|
+
hitSlop={hitSlop}
|
|
100
|
+
onPress={() => updateValue(resolvedValue + step)}
|
|
101
|
+
accessibilityRole="button"
|
|
102
|
+
accessibilityLabel="Increase value"
|
|
103
|
+
accessibilityState={{ disabled: incrementDisabled }}
|
|
104
|
+
style={[styles.button, webBlurStyle, incrementDisabled && styles.buttonDisabled]}
|
|
105
|
+
>
|
|
106
|
+
<PlusIcon />
|
|
107
|
+
</Pressable>
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const styles = StyleSheet.create({
|
|
113
|
+
base: {
|
|
114
|
+
flexDirection: "row",
|
|
115
|
+
alignItems: "center",
|
|
116
|
+
alignSelf: "flex-start",
|
|
117
|
+
gap: 17,
|
|
118
|
+
},
|
|
119
|
+
button: {
|
|
120
|
+
width: BUTTON_SIZE,
|
|
121
|
+
height: BUTTON_SIZE,
|
|
122
|
+
alignItems: "center",
|
|
123
|
+
justifyContent: "center",
|
|
124
|
+
borderWidth: 1,
|
|
125
|
+
borderColor: colors.whiteAlpha10,
|
|
126
|
+
borderRadius: 39,
|
|
127
|
+
},
|
|
128
|
+
buttonDisabled: {
|
|
129
|
+
opacity: 0.25,
|
|
130
|
+
},
|
|
131
|
+
value: {
|
|
132
|
+
...counterTypography,
|
|
133
|
+
color: colors.white,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { fn } from "storybook/test";
|
|
4
|
+
import { View, StyleSheet, Text } from "react-native";
|
|
5
|
+
import { DatePicker, type DateRange } from "./DatePicker";
|
|
6
|
+
import { colors } from "../../theme";
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: "Components/DatePicker",
|
|
10
|
+
component: DatePicker,
|
|
11
|
+
args: {
|
|
12
|
+
placeholder: "Select date",
|
|
13
|
+
defaultOpen: false,
|
|
14
|
+
disabled: false,
|
|
15
|
+
range: false,
|
|
16
|
+
onValueChange: fn(),
|
|
17
|
+
onOpenChange: fn(),
|
|
18
|
+
},
|
|
19
|
+
argTypes: {
|
|
20
|
+
range: { control: "boolean" },
|
|
21
|
+
onValueChange: { action: "valueChange" },
|
|
22
|
+
onOpenChange: { action: "openChange" },
|
|
23
|
+
},
|
|
24
|
+
parameters: {
|
|
25
|
+
backgrounds: { default: "black" },
|
|
26
|
+
},
|
|
27
|
+
} satisfies Meta<typeof DatePicker>;
|
|
28
|
+
|
|
29
|
+
export default meta;
|
|
30
|
+
type Story = StoryObj<typeof meta>;
|
|
31
|
+
|
|
32
|
+
export const Default: Story = {};
|
|
33
|
+
|
|
34
|
+
export const Open: Story = {
|
|
35
|
+
args: { defaultOpen: true },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const WithValue: Story = {
|
|
39
|
+
args: {
|
|
40
|
+
defaultValue: new Date(2026, 8, 15),
|
|
41
|
+
defaultOpen: true,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const Range: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
range: true,
|
|
48
|
+
placeholder: "Select dates",
|
|
49
|
+
defaultValue: { start: new Date(2026, 8, 15), end: new Date(2026, 8, 24) },
|
|
50
|
+
defaultOpen: true,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const RangeInProgress: Story = {
|
|
55
|
+
args: {
|
|
56
|
+
range: true,
|
|
57
|
+
placeholder: "Select dates",
|
|
58
|
+
defaultValue: { start: new Date(2026, 8, 15), end: null },
|
|
59
|
+
defaultOpen: true,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const MinMaxDate: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
defaultOpen: true,
|
|
66
|
+
minDate: new Date(2026, 8, 10),
|
|
67
|
+
maxDate: new Date(2026, 8, 20),
|
|
68
|
+
defaultVisibleMonth: new Date(2026, 8, 1),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const KeepOpenOnSelect: Story = {
|
|
73
|
+
args: {
|
|
74
|
+
defaultOpen: true,
|
|
75
|
+
closeOnSelect: false,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Disabled: Story = {
|
|
80
|
+
args: { disabled: true, defaultValue: new Date(2026, 8, 15) },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Verifies that a disabled DatePicker stays non-interactive even when forced open. */
|
|
84
|
+
export const DisabledOpen: Story = {
|
|
85
|
+
args: {
|
|
86
|
+
disabled: true,
|
|
87
|
+
defaultOpen: true,
|
|
88
|
+
defaultValue: new Date(2026, 8, 15),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function ControlledDatePicker() {
|
|
93
|
+
const [value, setValue] = useState<Date | null>(null);
|
|
94
|
+
const [open, setOpen] = useState(false);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<View style={storyStyles.column}>
|
|
98
|
+
<DatePicker
|
|
99
|
+
placeholder="Select date"
|
|
100
|
+
value={value}
|
|
101
|
+
onValueChange={setValue}
|
|
102
|
+
open={open}
|
|
103
|
+
onOpenChange={setOpen}
|
|
104
|
+
/>
|
|
105
|
+
<Text style={storyStyles.meta}>
|
|
106
|
+
value: {value ? value.toDateString() : "—"} · open: {String(open)}
|
|
107
|
+
</Text>
|
|
108
|
+
</View>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function ControlledRangeDatePicker() {
|
|
113
|
+
const [value, setValue] = useState<DateRange>({ start: null, end: null });
|
|
114
|
+
const [open, setOpen] = useState(true);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<View style={storyStyles.column}>
|
|
118
|
+
<DatePicker
|
|
119
|
+
range
|
|
120
|
+
placeholder="Select dates"
|
|
121
|
+
value={value}
|
|
122
|
+
onValueChange={setValue}
|
|
123
|
+
open={open}
|
|
124
|
+
onOpenChange={setOpen}
|
|
125
|
+
/>
|
|
126
|
+
<Text style={storyStyles.meta}>
|
|
127
|
+
{value.start ? value.start.toDateString() : "—"} →{" "}
|
|
128
|
+
{value.end ? value.end.toDateString() : "—"}
|
|
129
|
+
</Text>
|
|
130
|
+
</View>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const Controlled: Story = {
|
|
135
|
+
render: () => <ControlledDatePicker />,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const ControlledRange: Story = {
|
|
139
|
+
render: () => <ControlledRangeDatePicker />,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export const AllStates: Story = {
|
|
143
|
+
render: () => (
|
|
144
|
+
<View style={storyStyles.row}>
|
|
145
|
+
<DatePicker placeholder="Select date" />
|
|
146
|
+
<DatePicker placeholder="Select date" defaultOpen defaultValue={new Date(2026, 8, 15)} />
|
|
147
|
+
<DatePicker
|
|
148
|
+
range
|
|
149
|
+
placeholder="Select dates"
|
|
150
|
+
defaultValue={{ start: new Date(2026, 8, 15), end: new Date(2026, 8, 24) }}
|
|
151
|
+
defaultOpen
|
|
152
|
+
/>
|
|
153
|
+
</View>
|
|
154
|
+
),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const storyStyles = StyleSheet.create({
|
|
158
|
+
column: {
|
|
159
|
+
gap: 12,
|
|
160
|
+
alignItems: "flex-start",
|
|
161
|
+
},
|
|
162
|
+
row: {
|
|
163
|
+
flexDirection: "row",
|
|
164
|
+
flexWrap: "wrap",
|
|
165
|
+
gap: 24,
|
|
166
|
+
alignItems: "flex-start",
|
|
167
|
+
},
|
|
168
|
+
meta: {
|
|
169
|
+
color: colors.grey100,
|
|
170
|
+
fontSize: 12,
|
|
171
|
+
},
|
|
172
|
+
});
|