@ecohouse/ui 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +33 -13
  2. package/dist/index.cjs +1182 -104
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +206 -5
  5. package/dist/index.d.ts +206 -5
  6. package/dist/index.js +1171 -107
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/Avatar/Avatar.stories.tsx +133 -0
  10. package/src/components/Avatar/Avatar.tsx +337 -0
  11. package/src/components/Avatar/index.ts +2 -0
  12. package/src/components/DatePicker/DatePicker.stories.tsx +172 -0
  13. package/src/components/DatePicker/DatePicker.tsx +628 -0
  14. package/src/components/DatePicker/index.ts +7 -0
  15. package/src/components/Select/Select.tsx +1 -1
  16. package/src/components/index.ts +11 -0
  17. package/src/icons/Calendar/CalendarIcon.tsx +21 -0
  18. package/src/icons/Calendar/CalendarIcon.web.tsx +27 -0
  19. package/src/icons/Calendar/calendarPath.ts +3 -0
  20. package/src/icons/Calendar/index.ts +1 -0
  21. package/src/icons/CalendarOutline/CalendarOutlineIcon.tsx +25 -0
  22. package/src/icons/CalendarOutline/CalendarOutlineIcon.web.tsx +31 -0
  23. package/src/icons/CalendarOutline/calendarOutlinePath.ts +3 -0
  24. package/src/icons/CalendarOutline/index.ts +1 -0
  25. package/src/icons/ChevronLeft/ChevronLeftIcon.tsx +21 -0
  26. package/src/icons/ChevronLeft/ChevronLeftIcon.web.tsx +27 -0
  27. package/src/icons/ChevronLeft/chevronLeftPath.ts +2 -0
  28. package/src/icons/ChevronLeft/index.ts +1 -0
  29. package/src/icons/Heart/HeartIcon.tsx +23 -0
  30. package/src/icons/Heart/HeartIcon.web.tsx +29 -0
  31. package/src/icons/Heart/heartPath.ts +3 -0
  32. package/src/icons/Heart/index.ts +1 -0
  33. package/src/icons/HelpCircle/HelpCircleIcon.tsx +25 -0
  34. package/src/icons/HelpCircle/HelpCircleIcon.web.tsx +31 -0
  35. package/src/icons/HelpCircle/helpCirclePath.ts +3 -0
  36. package/src/icons/HelpCircle/index.ts +1 -0
  37. package/src/icons/LayoutGrid/LayoutGridIcon.tsx +25 -0
  38. package/src/icons/LayoutGrid/LayoutGridIcon.web.tsx +31 -0
  39. package/src/icons/LayoutGrid/index.ts +1 -0
  40. package/src/icons/LayoutGrid/layoutGridPath.ts +3 -0
  41. package/src/icons/LogOut/LogOutIcon.tsx +21 -0
  42. package/src/icons/LogOut/LogOutIcon.web.tsx +27 -0
  43. package/src/icons/LogOut/index.ts +1 -0
  44. package/src/icons/LogOut/logOutPath.ts +3 -0
  45. package/src/icons/Settings/SettingsIcon.tsx +21 -0
  46. package/src/icons/Settings/SettingsIcon.web.tsx +27 -0
  47. package/src/icons/Settings/index.ts +1 -0
  48. package/src/icons/Settings/settingsPath.ts +3 -0
  49. package/src/icons/Video/VideoIcon.tsx +24 -0
  50. package/src/icons/Video/VideoIcon.web.tsx +30 -0
  51. package/src/icons/Video/index.ts +1 -0
  52. package/src/icons/Video/videoPath.ts +5 -0
  53. package/src/icons/index.ts +9 -0
  54. package/src/icons/registry.ts +33 -3
  55. package/src/index.ts +30 -1
  56. package/src/primitives/MenuItem/MenuItem.stories.tsx +98 -0
  57. package/src/primitives/MenuItem/MenuItem.tsx +138 -0
  58. package/src/primitives/MenuItem/index.ts +2 -0
  59. package/src/primitives/index.ts +2 -0
  60. package/src/theme/colors.ts +2 -0
  61. package/src/theme/index.ts +2 -0
  62. package/src/theme/typography.ts +65 -0
  63. package/src/utils/dateUtils.ts +86 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -0,0 +1,133 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+ import { View, StyleSheet } from "react-native";
4
+ import { Avatar, type AvatarMenuItem } from "./Avatar";
5
+ import {
6
+ CalendarIcon,
7
+ HeartIcon,
8
+ HelpCircleIcon,
9
+ LayoutGridIcon,
10
+ LogOutIcon,
11
+ SettingsIcon,
12
+ VideoIcon,
13
+ } from "../../icons";
14
+ import { colors } from "../../theme";
15
+
16
+ const accountMenuItems: AvatarMenuItem[] = [
17
+ { label: "Bookings", icon: <CalendarIcon size={16} />, onPress: fn() },
18
+ { label: "Digital keys", icon: <LayoutGridIcon size={16} />, onPress: fn() },
19
+ { label: "Cameras", icon: <VideoIcon size={16} />, onPress: fn() },
20
+ { label: "Favorites", icon: <HeartIcon size={16} />, onPress: fn() },
21
+ { label: "Support", icon: <HelpCircleIcon size={16} />, onPress: fn() },
22
+ { label: "Settings", icon: <SettingsIcon size={16} />, onPress: fn() },
23
+ {
24
+ label: "Log out",
25
+ icon: <LogOutIcon size={16} />,
26
+ onPress: fn(),
27
+ backgroundColor: colors.redMuted,
28
+ hoverColor: colors.redHover,
29
+ },
30
+ ];
31
+
32
+ const meta = {
33
+ title: "Components/Avatar",
34
+ component: Avatar,
35
+ args: {
36
+ name: "Աննա Հակոբյան",
37
+ email: "anna.hakobyan@example.com",
38
+ imageUrl: "https://i.pravatar.cc/150?img=47",
39
+ showChevron: true,
40
+ disabled: false,
41
+ onPress: fn(),
42
+ },
43
+ argTypes: {
44
+ disabled: { control: "boolean" },
45
+ showChevron: { control: "boolean" },
46
+ onPress: { action: "pressed" },
47
+ },
48
+ parameters: {
49
+ backgrounds: { default: "black" },
50
+ },
51
+ } satisfies Meta<typeof Avatar>;
52
+
53
+ export default meta;
54
+ type Story = StoryObj<typeof meta>;
55
+
56
+ export const Default: Story = {};
57
+
58
+ /** No `imageUrl` — falls back to the placeholder person icon. */
59
+ export const WithoutImage: Story = {
60
+ args: { imageUrl: undefined },
61
+ };
62
+
63
+ /** A broken/unreachable URL also falls back to the placeholder icon once it fails to load. */
64
+ export const BrokenImage: Story = {
65
+ args: { imageUrl: "https://example.invalid/does-not-exist.png" },
66
+ };
67
+
68
+ export const WithoutEmail: Story = {
69
+ args: { email: undefined },
70
+ };
71
+
72
+ export const WithoutChevron: Story = {
73
+ args: { showChevron: false },
74
+ };
75
+
76
+ export const NotInteractive: Story = {
77
+ args: { onPress: undefined },
78
+ };
79
+
80
+ export const Disabled: Story = {
81
+ args: { disabled: true },
82
+ };
83
+
84
+ export const LongContent: Story = {
85
+ args: {
86
+ name: "Alexandra Konstantinopoulos-Harutyunyan",
87
+ email: "alexandra.konstantinopoulos.harutyunyan@example.com",
88
+ },
89
+ };
90
+
91
+ /** Options — icon, label, action, and per-item colors — come entirely from the consumer. */
92
+ export const WithMenu: Story = {
93
+ args: {
94
+ menuItems: accountMenuItems,
95
+ defaultOpen: true,
96
+ },
97
+ };
98
+
99
+ export const WithMenuClosed: Story = {
100
+ name: "With Menu / Closed",
101
+ args: {
102
+ menuItems: accountMenuItems,
103
+ },
104
+ };
105
+
106
+ export const AllStates: Story = {
107
+ render: () => (
108
+ <View style={storyStyles.column}>
109
+ <Avatar
110
+ name="Աննա Հակոբյան"
111
+ email="anna.hakobyan@example.com"
112
+ imageUrl="https://i.pravatar.cc/150?img=47"
113
+ onPress={fn()}
114
+ />
115
+ <Avatar name="Աննա Հակոբյան" email="anna.hakobyan@example.com" onPress={fn()} />
116
+ <Avatar
117
+ name="Աննա Հակոբյան"
118
+ email="anna.hakobyan@example.com"
119
+ imageUrl="https://i.pravatar.cc/150?img=47"
120
+ disabled
121
+ onPress={fn()}
122
+ />
123
+ <Avatar name="Աննա Հակոբյան" showChevron={false} onPress={fn()} />
124
+ </View>
125
+ ),
126
+ };
127
+
128
+ const storyStyles = StyleSheet.create({
129
+ column: {
130
+ gap: 16,
131
+ alignItems: "flex-start",
132
+ },
133
+ });
@@ -0,0 +1,337 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type ComponentRef,
9
+ } from "react";
10
+ import {
11
+ Image,
12
+ Platform,
13
+ Pressable,
14
+ StyleSheet,
15
+ Text,
16
+ View,
17
+ type GestureResponderEvent,
18
+ type PressableProps,
19
+ type StyleProp,
20
+ type ViewStyle,
21
+ } from "react-native";
22
+ import { ChevronDownIcon, UserIcon } from "../../icons";
23
+ import { avatarTypography, colors } from "../../theme";
24
+ import { mergeRefs } from "../../utils/mergeRefs";
25
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
26
+ import { MenuItem, type MenuItemProps } from "../../primitives";
27
+
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`. */
34
+ key?: string;
35
+ onPress: () => void;
36
+ };
37
+
38
+ export interface AvatarProps extends Omit<
39
+ PressableProps,
40
+ "onPress" | "disabled" | "style" | "children" | "hitSlop"
41
+ > {
42
+ /** Logged-in user's display name. */
43
+ name: string;
44
+ /** Shown under the name, e.g. the user's email. */
45
+ email?: string;
46
+ /** Photo URL; falls back to a placeholder icon when missing or when it fails to load. */
47
+ imageUrl?: string | null;
48
+ /** Called on every press, in addition to any menu toggling driven by `menuItems`. */
49
+ onPress?: (event: GestureResponderEvent) => void;
50
+ /** External — non-interactive. */
51
+ disabled?: boolean;
52
+ /** Trailing chevron, typically used to hint a dropdown/menu. */
53
+ showChevron?: boolean;
54
+ /**
55
+ * Account-menu options, supplied entirely by the consumer (icon, label,
56
+ * action, and per-item colors). When set, pressing the chip opens a
57
+ * dropdown listing them via `MenuItem`.
58
+ */
59
+ menuItems?: AvatarMenuItem[];
60
+ /** Controlled open state for the menu. */
61
+ open?: boolean;
62
+ defaultOpen?: boolean;
63
+ onOpenChange?: (open: boolean) => void;
64
+ /** Fixed menu width; defaults to matching the chip's width. */
65
+ menuWidth?: number;
66
+ style?: StyleProp<ViewStyle>;
67
+ className?: string;
68
+ hitSlop?: PressableProps["hitSlop"];
69
+ }
70
+
71
+ const IMAGE_SIZE = 48;
72
+ const CONTAINER_RADIUS = 71;
73
+ const CHEVRON_SIZE = 20;
74
+ const FALLBACK_ICON_SIZE = 20;
75
+ const MENU_RADIUS = 16;
76
+
77
+ export const Avatar = forwardRef<ComponentRef<typeof View>, AvatarProps>(function Avatar(
78
+ {
79
+ name,
80
+ email,
81
+ imageUrl,
82
+ onPress,
83
+ disabled = false,
84
+ showChevron = true,
85
+ menuItems,
86
+ open: openProp,
87
+ defaultOpen = false,
88
+ onOpenChange,
89
+ menuWidth,
90
+ style,
91
+ className,
92
+ accessibilityLabel,
93
+ hitSlop = 8,
94
+ ...rest
95
+ },
96
+ forwardedRef,
97
+ ) {
98
+ const wrapperRef = useRef<ComponentRef<typeof View>>(null);
99
+ const containerRef = useRef<ComponentRef<typeof View>>(null);
100
+ const setContainerRef = useMemo(() => mergeRefs(containerRef, forwardedRef), [forwardedRef]);
101
+ const [imageFailed, setImageFailed] = useState(false);
102
+
103
+ const resolvedMenuItems = menuItems ?? [];
104
+ const hasMenu = resolvedMenuItems.length > 0;
105
+ const isInteractive = onPress != null || hasMenu;
106
+ const isOpenControlled = typeof openProp === "boolean";
107
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
108
+ const isOpen = hasMenu && (isOpenControlled ? openProp : uncontrolledOpen);
109
+
110
+ useApplyWebClassName(containerRef, className);
111
+
112
+ const setOpen = useCallback(
113
+ (next: boolean) => {
114
+ if (!isOpenControlled) setUncontrolledOpen(next);
115
+ onOpenChange?.(next);
116
+ },
117
+ [isOpenControlled, onOpenChange],
118
+ );
119
+
120
+ const focusTrigger = useCallback(() => {
121
+ const node = containerRef.current as unknown as { focus?: () => void } | null;
122
+ node?.focus?.();
123
+ }, []);
124
+
125
+ const closeAndFocusTrigger = useCallback(() => {
126
+ setOpen(false);
127
+ focusTrigger();
128
+ }, [focusTrigger, setOpen]);
129
+
130
+ const handlePress = (event: GestureResponderEvent) => {
131
+ onPress?.(event);
132
+ if (hasMenu) setOpen(!isOpen);
133
+ };
134
+
135
+ const handleItemPress = useCallback(
136
+ (item: AvatarMenuItem) => {
137
+ if (item.disabled) return;
138
+ item.onPress();
139
+ closeAndFocusTrigger();
140
+ },
141
+ [closeAndFocusTrigger],
142
+ );
143
+
144
+ // Close on outside click (web only; native has no document-level events).
145
+ useEffect(() => {
146
+ if (Platform.OS !== "web" || !isOpen) return;
147
+
148
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
149
+ const wrapper = wrapperRef.current as unknown as {
150
+ contains?: (node: Node) => boolean;
151
+ } | null;
152
+ const target = event.target;
153
+ if (wrapper?.contains && target instanceof Node && !wrapper.contains(target)) {
154
+ setOpen(false);
155
+ }
156
+ };
157
+
158
+ document.addEventListener("mousedown", handlePointerDown);
159
+ document.addEventListener("touchstart", handlePointerDown);
160
+ return () => {
161
+ document.removeEventListener("mousedown", handlePointerDown);
162
+ document.removeEventListener("touchstart", handlePointerDown);
163
+ };
164
+ }, [isOpen, setOpen]);
165
+
166
+ // Escape closes the menu and returns focus to the trigger (web only).
167
+ useEffect(() => {
168
+ if (Platform.OS !== "web" || !isOpen) return;
169
+
170
+ const handleKeyDown = (event: KeyboardEvent) => {
171
+ if (event.key === "Escape") {
172
+ event.preventDefault();
173
+ closeAndFocusTrigger();
174
+ }
175
+ };
176
+
177
+ document.addEventListener("keydown", handleKeyDown);
178
+ return () => document.removeEventListener("keydown", handleKeyDown);
179
+ }, [isOpen, closeAndFocusTrigger]);
180
+
181
+ const hasImage = Boolean(imageUrl) && !imageFailed;
182
+ const resolvedAccessibilityLabel = accessibilityLabel ?? [name, email].filter(Boolean).join(", ");
183
+
184
+ return (
185
+ <View ref={wrapperRef} style={[styles.wrapper, isOpen && styles.wrapperOpen]}>
186
+ <Pressable
187
+ {...rest}
188
+ ref={setContainerRef}
189
+ onPress={handlePress}
190
+ disabled={disabled || !isInteractive}
191
+ hitSlop={isInteractive ? hitSlop : undefined}
192
+ accessibilityRole={isInteractive ? "button" : undefined}
193
+ accessibilityLabel={resolvedAccessibilityLabel}
194
+ accessibilityState={
195
+ isInteractive ? { disabled, expanded: hasMenu ? isOpen : undefined } : undefined
196
+ }
197
+ style={[styles.root, disabled && styles.disabled, style]}
198
+ >
199
+ <View style={styles.imageWrap}>
200
+ {hasImage ? (
201
+ <Image
202
+ source={{ uri: imageUrl ?? undefined }}
203
+ style={styles.image}
204
+ onError={() => setImageFailed(true)}
205
+ />
206
+ ) : (
207
+ <View style={styles.imageFallback}>
208
+ <UserIcon size={FALLBACK_ICON_SIZE} color={colors.primary} />
209
+ </View>
210
+ )}
211
+ </View>
212
+
213
+ <View style={styles.textColumn}>
214
+ <Text style={styles.name} numberOfLines={1}>
215
+ {name}
216
+ </Text>
217
+ {email ? (
218
+ <Text style={styles.email} numberOfLines={1}>
219
+ {email}
220
+ </Text>
221
+ ) : null}
222
+ </View>
223
+
224
+ {showChevron ? (
225
+ <View style={[styles.chevronSlot, isOpen && styles.chevronOpen]}>
226
+ <ChevronDownIcon size={CHEVRON_SIZE} color={isOpen ? colors.primary : colors.grey100} />
227
+ </View>
228
+ ) : null}
229
+ </Pressable>
230
+
231
+ {hasMenu && isOpen ? (
232
+ <View style={[styles.menu, menuWidth != null && { right: undefined, width: menuWidth }]}>
233
+ {resolvedMenuItems.map((item) => {
234
+ const itemKey = item.key ?? item.label;
235
+ return (
236
+ <MenuItem
237
+ key={itemKey}
238
+ icon={item.icon}
239
+ label={item.label}
240
+ disabled={item.disabled}
241
+ backgroundColor={item.backgroundColor}
242
+ hoverColor={item.hoverColor}
243
+ onPress={() => handleItemPress(item)}
244
+ />
245
+ );
246
+ })}
247
+ </View>
248
+ ) : null}
249
+ </View>
250
+ );
251
+ });
252
+
253
+ const styles = StyleSheet.create({
254
+ wrapper: {
255
+ position: "relative",
256
+ alignSelf: "flex-start",
257
+ },
258
+ wrapperOpen: {
259
+ zIndex: 20,
260
+ },
261
+ root: {
262
+ flexDirection: "row",
263
+ alignItems: "center",
264
+ alignSelf: "flex-start",
265
+ gap: 12,
266
+ paddingTop: 4,
267
+ paddingBottom: 4,
268
+ paddingLeft: 4,
269
+ paddingRight: 12,
270
+ borderRadius: CONTAINER_RADIUS,
271
+ backgroundColor: colors.grey700,
272
+ },
273
+ disabled: {
274
+ opacity: 0.6,
275
+ },
276
+ imageWrap: {
277
+ width: IMAGE_SIZE,
278
+ height: IMAGE_SIZE,
279
+ borderRadius: IMAGE_SIZE / 2,
280
+ overflow: "hidden",
281
+ flexShrink: 0,
282
+ },
283
+ image: {
284
+ width: IMAGE_SIZE,
285
+ height: IMAGE_SIZE,
286
+ },
287
+ imageFallback: {
288
+ width: IMAGE_SIZE,
289
+ height: IMAGE_SIZE,
290
+ alignItems: "center",
291
+ justifyContent: "center",
292
+ backgroundColor: colors.grey600,
293
+ },
294
+ textColumn: {
295
+ minWidth: 0,
296
+ flexShrink: 1,
297
+ gap: 3,
298
+ },
299
+ name: {
300
+ ...avatarTypography.name,
301
+ color: colors.white,
302
+ },
303
+ email: {
304
+ ...avatarTypography.email,
305
+ color: colors.grey0,
306
+ },
307
+ chevronSlot: {
308
+ width: CHEVRON_SIZE,
309
+ height: CHEVRON_SIZE,
310
+ alignItems: "center",
311
+ justifyContent: "center",
312
+ flexShrink: 0,
313
+ },
314
+ chevronOpen: {
315
+ transform: [{ rotate: "180deg" }],
316
+ },
317
+ menu: {
318
+ position: "absolute",
319
+ top: "100%",
320
+ left: 0,
321
+ right: 0,
322
+ marginTop: 8,
323
+ zIndex: 10,
324
+ borderRadius: MENU_RADIUS,
325
+ padding: 8,
326
+ gap: 8,
327
+ backgroundColor: colors.grey700,
328
+ ...Platform.select({
329
+ web: {
330
+ boxShadow: `0 8px 24px ${colors.black}73`,
331
+ },
332
+ default: {
333
+ elevation: 8,
334
+ },
335
+ }),
336
+ },
337
+ });
@@ -0,0 +1,2 @@
1
+ export { Avatar } from "./Avatar";
2
+ export type { AvatarProps, AvatarMenuItem } from "./Avatar";
@@ -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
+ });