@ecohouse/ui 0.1.33 → 0.1.35

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 (56) hide show
  1. package/dist/index.cjs +30078 -656
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +114 -7
  4. package/dist/index.d.ts +114 -7
  5. package/dist/index.js +30007 -593
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/components/AccordionItem/AccordionItem.stories.tsx +54 -0
  9. package/src/components/AccordionItem/AccordionItem.tsx +223 -0
  10. package/src/components/AccordionItem/index.ts +2 -0
  11. package/src/components/AccountHeader/AccountHeader.tsx +50 -29
  12. package/src/components/Button/Button.stories.tsx +10 -1
  13. package/src/components/Button/Button.tsx +32 -3
  14. package/src/components/ContactInfoCard/ContactInfoCard.stories.tsx +30 -0
  15. package/src/components/ContactInfoCard/ContactInfoCard.tsx +141 -0
  16. package/src/components/ContactInfoCard/index.ts +2 -0
  17. package/src/components/DatePicker/DatePicker.tsx +51 -15
  18. package/src/components/Input/Input.tsx +54 -21
  19. package/src/components/LanguageSwitcher/LanguageSwitcher.stories.tsx +11 -0
  20. package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +103 -19
  21. package/src/components/LanguageSwitcher/index.ts +5 -1
  22. package/src/components/Modal/Modal.stories.tsx +100 -0
  23. package/src/components/Modal/Modal.tsx +165 -0
  24. package/src/components/Modal/index.ts +2 -0
  25. package/src/components/Select/Select.tsx +90 -5
  26. package/src/components/Sidebar/Sidebar.tsx +19 -3
  27. package/src/components/Textarea/Textarea.tsx +29 -4
  28. package/src/components/ToggleRow/ToggleRow.stories.tsx +46 -0
  29. package/src/components/ToggleRow/ToggleRow.tsx +121 -0
  30. package/src/components/ToggleRow/index.ts +2 -0
  31. package/src/components/VerificationCodeInput/VerificationCodeInput.tsx +80 -60
  32. package/src/components/index.ts +17 -1
  33. package/src/icons/CameraFilled/CameraFilledIcon.tsx +15 -0
  34. package/src/icons/CameraFilled/CameraFilledIcon.web.tsx +21 -0
  35. package/src/icons/CameraFilled/cameraFilledPath.ts +2 -0
  36. package/src/icons/CameraFilled/index.ts +1 -0
  37. package/src/icons/Globe/GlobeIcon.tsx +2 -2
  38. package/src/icons/Globe/GlobeIcon.web.tsx +2 -2
  39. package/src/icons/Globe/globePath.ts +2 -2
  40. package/src/icons/Headset/HeadsetIcon.tsx +26 -0
  41. package/src/icons/Headset/HeadsetIcon.web.tsx +25 -0
  42. package/src/icons/Headset/headsetPath.ts +6 -0
  43. package/src/icons/Headset/index.ts +1 -0
  44. package/src/icons/Hide/HideIcon.tsx +21 -0
  45. package/src/icons/Hide/HideIcon.web.tsx +27 -0
  46. package/src/icons/Hide/hidePath.ts +3 -0
  47. package/src/icons/Hide/index.ts +1 -0
  48. package/src/icons/Key/keyPath.ts +1 -1
  49. package/src/icons/MenuLines/MenuLinesIcon.tsx +20 -0
  50. package/src/icons/MenuLines/MenuLinesIcon.web.tsx +26 -0
  51. package/src/icons/MenuLines/index.ts +1 -0
  52. package/src/icons/MenuLines/menuLinesPath.ts +3 -0
  53. package/src/icons/index.ts +4 -0
  54. package/src/icons/registry.ts +11 -0
  55. package/src/index.ts +13 -0
  56. package/src/theme/typography.ts +3 -3
@@ -0,0 +1,100 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { Pressable, StyleSheet, Text, View } from "react-native";
4
+ import { colors, fonts } from "../../theme";
5
+ import { Modal } from "./Modal";
6
+
7
+ const meta = {
8
+ title: "Components/Modal",
9
+ component: Modal,
10
+ parameters: {
11
+ backgrounds: { default: "black" },
12
+ },
13
+ } satisfies Meta<typeof Modal>;
14
+
15
+ export default meta;
16
+ type Story = StoryObj<typeof meta>;
17
+
18
+ function ModalDemo() {
19
+ const [open, setOpen] = useState(true);
20
+
21
+ return (
22
+ <View style={styles.page}>
23
+ <Pressable onPress={() => setOpen(true)} style={styles.openButton}>
24
+ <Text style={styles.openLabel}>Open modal</Text>
25
+ </Pressable>
26
+
27
+ <Modal open={open} onClose={() => setOpen(false)}>
28
+ <View style={styles.panel}>
29
+ <Text style={styles.title}>Modal</Text>
30
+ <Text style={styles.body}>Backdrop uses #09090933 + blur(20px).</Text>
31
+ <Pressable onPress={() => setOpen(false)} style={styles.closeButton}>
32
+ <Text style={styles.closeLabel}>Close</Text>
33
+ </Pressable>
34
+ </View>
35
+ </Modal>
36
+ </View>
37
+ );
38
+ }
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ open: true,
43
+ onClose: () => undefined,
44
+ children: null,
45
+ },
46
+ render: () => <ModalDemo />,
47
+ };
48
+
49
+ const styles = StyleSheet.create({
50
+ page: {
51
+ minHeight: 360,
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ padding: 24,
55
+ },
56
+ openButton: {
57
+ paddingHorizontal: 16,
58
+ paddingVertical: 10,
59
+ borderRadius: 40,
60
+ backgroundColor: colors.primary,
61
+ },
62
+ openLabel: {
63
+ fontFamily: fonts.sans,
64
+ fontSize: 14,
65
+ fontWeight: "600",
66
+ color: colors.white,
67
+ },
68
+ panel: {
69
+ width: 320,
70
+ gap: 16,
71
+ padding: 24,
72
+ borderRadius: 24,
73
+ backgroundColor: colors.dark,
74
+ },
75
+ title: {
76
+ fontFamily: fonts.sans,
77
+ fontSize: 18,
78
+ fontWeight: "700",
79
+ color: colors.white,
80
+ },
81
+ body: {
82
+ fontFamily: fonts.sans,
83
+ fontSize: 14,
84
+ lineHeight: 17.5,
85
+ color: colors.grey100,
86
+ },
87
+ closeButton: {
88
+ alignSelf: "flex-start",
89
+ paddingHorizontal: 16,
90
+ paddingVertical: 10,
91
+ borderRadius: 40,
92
+ backgroundColor: colors.grey700,
93
+ },
94
+ closeLabel: {
95
+ fontFamily: fonts.sans,
96
+ fontSize: 14,
97
+ fontWeight: "600",
98
+ color: colors.white,
99
+ },
100
+ });
@@ -0,0 +1,165 @@
1
+ import { forwardRef, useEffect, useMemo, useRef, type ComponentRef, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import {
4
+ Modal as RNModal,
5
+ Platform,
6
+ Pressable,
7
+ StyleSheet,
8
+ View,
9
+ type StyleProp,
10
+ type ViewStyle,
11
+ } from "react-native";
12
+ import { mergeRefs } from "../../utils/mergeRefs";
13
+ import { useApplyWebClassName } from "../../utils/useApplyWebClassName";
14
+
15
+ export interface ModalProps {
16
+ /** Controls visibility. */
17
+ open: boolean;
18
+ /** Called on backdrop press, Escape / Android back, or explicit close. */
19
+ onClose: () => void;
20
+ children: ReactNode;
21
+ /** Close when the dimmed backdrop is pressed. Defaults to `true`. */
22
+ closeOnBackdropPress?: boolean;
23
+ /** Accessible name for the backdrop control. Defaults to `"Close"`. */
24
+ backdropAccessibilityLabel?: string;
25
+ /** Style for the centered content wrapper around `children`. */
26
+ contentStyle?: StyleProp<ViewStyle>;
27
+ /** Web CSS class applied to the content wrapper. */
28
+ className?: string;
29
+ }
30
+
31
+ const BACKDROP_COLOR = "#09090933";
32
+ const WEB_Z_INDEX = 500;
33
+
34
+ /** Plain DOM backdrop — RN StyleSheet / Modal host often drops `backdrop-filter`. */
35
+ const webBackdropDomStyle = {
36
+ position: "absolute",
37
+ top: 0,
38
+ right: 0,
39
+ bottom: 0,
40
+ left: 0,
41
+ backgroundColor: BACKDROP_COLOR,
42
+ backdropFilter: "blur(20px)",
43
+ WebkitBackdropFilter: "blur(20px)",
44
+ border: "none",
45
+ padding: 0,
46
+ margin: 0,
47
+ cursor: "pointer",
48
+ } as const;
49
+
50
+ const webRootDomStyle = {
51
+ position: "fixed",
52
+ top: 0,
53
+ right: 0,
54
+ bottom: 0,
55
+ left: 0,
56
+ zIndex: WEB_Z_INDEX,
57
+ display: "flex",
58
+ alignItems: "center",
59
+ justifyContent: "center",
60
+ } as const;
61
+
62
+ export const Modal = forwardRef<ComponentRef<typeof View>, ModalProps>(function Modal(
63
+ {
64
+ open,
65
+ onClose,
66
+ children,
67
+ closeOnBackdropPress = true,
68
+ backdropAccessibilityLabel = "Close",
69
+ contentStyle,
70
+ className,
71
+ },
72
+ forwardedRef,
73
+ ) {
74
+ const contentRef = useRef<ComponentRef<typeof View>>(null);
75
+ const setContentRef = useMemo(() => mergeRefs(contentRef, forwardedRef), [forwardedRef]);
76
+ const resolvedClassName = className?.trim() || undefined;
77
+
78
+ useApplyWebClassName(contentRef, resolvedClassName, open && Platform.OS === "web");
79
+
80
+ useEffect(() => {
81
+ if (!open || Platform.OS !== "web") return undefined;
82
+
83
+ const previousOverflow = document.body.style.overflow;
84
+ document.body.style.overflow = "hidden";
85
+
86
+ const onKeyDown = (event: KeyboardEvent) => {
87
+ if (event.key === "Escape") onClose();
88
+ };
89
+ document.addEventListener("keydown", onKeyDown);
90
+
91
+ return () => {
92
+ document.body.style.overflow = previousOverflow;
93
+ document.removeEventListener("keydown", onKeyDown);
94
+ };
95
+ }, [open, onClose]);
96
+
97
+ const content = (
98
+ <View
99
+ ref={setContentRef}
100
+ style={[styles.content, contentStyle]}
101
+ onStartShouldSetResponder={() => true}
102
+ >
103
+ {children}
104
+ </View>
105
+ );
106
+
107
+ // Web: portal + real DOM backdrop so `backdrop-filter` blurs the page behind.
108
+ if (Platform.OS === "web") {
109
+ if (!open || typeof document === "undefined") return null;
110
+
111
+ return createPortal(
112
+ <div style={webRootDomStyle} role="presentation">
113
+ <button
114
+ type="button"
115
+ aria-label={backdropAccessibilityLabel}
116
+ disabled={!closeOnBackdropPress}
117
+ onClick={closeOnBackdropPress ? onClose : undefined}
118
+ style={webBackdropDomStyle}
119
+ />
120
+ <div style={{ position: "relative", zIndex: 1, maxWidth: "100%", maxHeight: "100%" }}>
121
+ {content}
122
+ </div>
123
+ </div>,
124
+ document.body,
125
+ );
126
+ }
127
+
128
+ return (
129
+ <RNModal
130
+ visible={open}
131
+ transparent
132
+ animationType="fade"
133
+ onRequestClose={onClose}
134
+ statusBarTranslucent
135
+ >
136
+ <View style={styles.root} accessibilityViewIsModal>
137
+ <Pressable
138
+ accessibilityRole="button"
139
+ accessibilityLabel={backdropAccessibilityLabel}
140
+ disabled={!closeOnBackdropPress}
141
+ onPress={closeOnBackdropPress ? onClose : undefined}
142
+ style={styles.backdrop}
143
+ />
144
+ {content}
145
+ </View>
146
+ </RNModal>
147
+ );
148
+ });
149
+
150
+ const styles = StyleSheet.create({
151
+ root: {
152
+ flex: 1,
153
+ alignItems: "center",
154
+ justifyContent: "center",
155
+ },
156
+ backdrop: {
157
+ ...StyleSheet.absoluteFillObject,
158
+ backgroundColor: BACKDROP_COLOR,
159
+ },
160
+ content: {
161
+ zIndex: 1,
162
+ maxWidth: "100%",
163
+ maxHeight: "100%",
164
+ },
165
+ });
@@ -0,0 +1,2 @@
1
+ export { Modal } from "./Modal";
2
+ export type { ModalProps } from "./Modal";
@@ -2,6 +2,7 @@ import {
2
2
  forwardRef,
3
3
  useCallback,
4
4
  useEffect,
5
+ useLayoutEffect,
5
6
  useMemo,
6
7
  useRef,
7
8
  useState,
@@ -64,6 +65,8 @@ type SelectBaseProps = Omit<ViewProps, "style" | "children"> & {
64
65
  /** Override color for the selected value text in the trigger. */
65
66
  valueColor?: string;
66
67
  style?: StyleProp<ViewStyle>;
68
+ /** Styles applied to the dropdown menu only (not the trigger). */
69
+ menuStyle?: StyleProp<ViewStyle>;
67
70
  className?: string;
68
71
  };
69
72
 
@@ -268,6 +271,7 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
268
271
  emptyText = "No results",
269
272
  valueColor,
270
273
  style,
274
+ menuStyle,
271
275
  className,
272
276
  accessibilityLabel,
273
277
  // Pulled out only to keep them off `rest` (which is spread onto the root View);
@@ -297,6 +301,10 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
297
301
  const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
298
302
  const [searchQuery, setSearchQuery] = useState("");
299
303
  const [hoveredValue, setHoveredValue] = useState<string | null>(null);
304
+ /** Web: flip menu to the trigger's right edge when it would overflow the viewport. */
305
+ const [menuAlignEnd, setMenuAlignEnd] = useState(false);
306
+ /** Web: open above the trigger when there isn't enough space below. */
307
+ const [menuPlaceAbove, setMenuPlaceAbove] = useState(false);
300
308
 
301
309
  const selectedValues = useMemo(() => {
302
310
  const current = isValueControlled
@@ -330,6 +338,25 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
330
338
 
331
339
  useApplyWebClassName(rootRef, className);
332
340
 
341
+ const resolvedMenuWidth = useMemo(() => {
342
+ const flat = StyleSheet.flatten(menuStyle);
343
+ const width = flat?.width;
344
+ if (typeof width === "number") return width;
345
+ const minWidth = flat?.minWidth;
346
+ if (typeof minWidth === "number") return minWidth;
347
+ return null;
348
+ }, [menuStyle]);
349
+
350
+ const estimatedMenuHeight = useMemo(() => {
351
+ const searchBlock = showSearch ? 40 + 8 : 0;
352
+ const optionCount = Math.max(filteredOptions.length, 1);
353
+ const listHeight = Math.min(
354
+ optionCount * 43 + Math.max(0, optionCount - 1) * 8,
355
+ MENU_MAX_LIST_HEIGHT,
356
+ );
357
+ return 24 + searchBlock + listHeight;
358
+ }, [filteredOptions.length, showSearch]);
359
+
333
360
  const setOpen = useCallback(
334
361
  (next: boolean) => {
335
362
  if (!isOpenControlled) setUncontrolledOpen(next);
@@ -341,6 +368,8 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
341
368
  } else {
342
369
  setSearchQuery("");
343
370
  setHoveredValue(null);
371
+ setMenuAlignEnd(false);
372
+ setMenuPlaceAbove(false);
344
373
  // Let the parent reset its externally fetched options.
345
374
  onSearchChange?.("");
346
375
  }
@@ -348,6 +377,38 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
348
377
  [isOpenControlled, onOpenChange, onSearchChange, selectedValues],
349
378
  );
350
379
 
380
+ // Prefer start (left) / below; flip when the menu would overflow the viewport (web).
381
+ useLayoutEffect(() => {
382
+ if (Platform.OS !== "web" || !isOpen) return;
383
+
384
+ const updateMenuPlacement = () => {
385
+ const trigger = triggerRef.current as unknown as {
386
+ getBoundingClientRect?: () => DOMRect;
387
+ } | null;
388
+ const rect = trigger?.getBoundingClientRect?.();
389
+ if (!rect) return;
390
+
391
+ const viewportPadding = 8;
392
+ const gap = 8;
393
+ const menuWidth = Math.max(rect.width, resolvedMenuWidth ?? 0);
394
+ const overflowsRight = rect.left + menuWidth > window.innerWidth - viewportPadding;
395
+ setMenuAlignEnd(overflowsRight);
396
+
397
+ const spaceBelow = window.innerHeight - viewportPadding - rect.bottom;
398
+ const spaceAbove = rect.top - viewportPadding;
399
+ const needsAbove = estimatedMenuHeight + gap > spaceBelow && spaceAbove > spaceBelow;
400
+ setMenuPlaceAbove(needsAbove);
401
+ };
402
+
403
+ updateMenuPlacement();
404
+ window.addEventListener("resize", updateMenuPlacement);
405
+ window.addEventListener("scroll", updateMenuPlacement, true);
406
+ return () => {
407
+ window.removeEventListener("resize", updateMenuPlacement);
408
+ window.removeEventListener("scroll", updateMenuPlacement, true);
409
+ };
410
+ }, [estimatedMenuHeight, isOpen, resolvedMenuWidth]);
411
+
351
412
  const handleSearchChange = (text: string) => {
352
413
  setSearchQuery(text);
353
414
  onSearchChange?.(text);
@@ -583,7 +644,14 @@ export const Select = forwardRef<ComponentRef<typeof View>, SelectProps>(
583
644
  </Pressable>
584
645
 
585
646
  {isOpen ? (
586
- <View style={styles.menu}>
647
+ <View
648
+ style={[
649
+ styles.menu,
650
+ menuAlignEnd ? styles.menuAlignEnd : styles.menuAlignStart,
651
+ menuPlaceAbove ? styles.menuAbove : styles.menuBelow,
652
+ menuStyle,
653
+ ]}
654
+ >
587
655
  {showSearch ? (
588
656
  <View style={styles.searchField}>
589
657
  <View style={styles.iconSlot}>
@@ -777,10 +845,7 @@ const styles = StyleSheet.create({
777
845
  },
778
846
  menu: {
779
847
  position: "absolute",
780
- top: "100%",
781
- left: 0,
782
- right: 0,
783
- marginTop: 8,
848
+ minWidth: "100%",
784
849
  zIndex: 10,
785
850
  borderRadius: MENU_RADIUS,
786
851
  padding: 12,
@@ -795,6 +860,26 @@ const styles = StyleSheet.create({
795
860
  },
796
861
  }),
797
862
  },
863
+ menuAlignStart: {
864
+ left: 0,
865
+ right: "auto",
866
+ },
867
+ menuAlignEnd: {
868
+ left: "auto",
869
+ right: 0,
870
+ },
871
+ menuBelow: {
872
+ top: "100%",
873
+ bottom: "auto",
874
+ marginTop: 8,
875
+ marginBottom: 0,
876
+ },
877
+ menuAbove: {
878
+ top: "auto",
879
+ bottom: "100%",
880
+ marginTop: 0,
881
+ marginBottom: 8,
882
+ },
798
883
  optionList: {
799
884
  maxHeight: MENU_MAX_LIST_HEIGHT,
800
885
  },
@@ -18,7 +18,7 @@ import {
18
18
  type ViewStyle,
19
19
  } from "react-native";
20
20
  import type { IconComponent } from "../../icons";
21
- import { SidebarIcon } from "../../icons";
21
+ import { CloseIcon, SidebarIcon } from "../../icons";
22
22
  import { BrandLogo, MenuItem } from "../../primitives";
23
23
  import { colors, fonts } from "../../theme";
24
24
  import { mergeRefs } from "../../utils/mergeRefs";
@@ -61,6 +61,10 @@ export interface SidebarProps extends Omit<ViewProps, "style" | "children"> {
61
61
  onCollapsedChange?: (collapsed: boolean) => void;
62
62
  /** When false, hides the expand/collapse control. Defaults to `true`. */
63
63
  showCollapseToggle?: boolean;
64
+ /** When set, shows a close control in the header (e.g. mobile drawer). */
65
+ onClose?: () => void;
66
+ /** Accessible label for the close control. Defaults to `"Close"`. */
67
+ closeAccessibilityLabel?: string;
64
68
  style?: StyleProp<ViewStyle>;
65
69
  className?: string;
66
70
  }
@@ -89,6 +93,8 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
89
93
  defaultCollapsed = false,
90
94
  onCollapsedChange,
91
95
  showCollapseToggle = true,
96
+ onClose,
97
+ closeAccessibilityLabel = "Close",
92
98
  style,
93
99
  className,
94
100
  ...rest
@@ -143,7 +149,7 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
143
149
  style={[
144
150
  styles.header,
145
151
  collapsed ? styles.headerCollapsed : styles.headerExpanded,
146
- !showCollapseToggle ? styles.headerNoToggle : null,
152
+ !showCollapseToggle && !onClose ? styles.headerNoToggle : null,
147
153
  ]}
148
154
  >
149
155
  <Animated.View
@@ -166,7 +172,17 @@ export const Sidebar = forwardRef<ComponentRef<typeof View>, SidebarProps>(funct
166
172
  />
167
173
  </Animated.View>
168
174
 
169
- {showCollapseToggle ? (
175
+ {onClose ? (
176
+ <Pressable
177
+ accessibilityRole="button"
178
+ accessibilityLabel={closeAccessibilityLabel}
179
+ hitSlop={8}
180
+ onPress={onClose}
181
+ style={styles.toggleButton}
182
+ >
183
+ <CloseIcon color={colors.grey100} size={TOGGLE_SIZE} strokeWidth={2} />
184
+ </Pressable>
185
+ ) : showCollapseToggle ? (
170
186
  <Pressable
171
187
  accessibilityRole="button"
172
188
  accessibilityLabel={collapsed ? expandAccessibilityLabel : collapseAccessibilityLabel}
@@ -1,6 +1,7 @@
1
- import { forwardRef, useMemo, useRef, useState, type ComponentRef } from "react";
1
+ import { forwardRef, useId, useMemo, useRef, useState, type ComponentRef } from "react";
2
2
  import {
3
3
  Platform,
4
+ Pressable,
4
5
  StyleSheet,
5
6
  Text,
6
7
  TextInput,
@@ -132,6 +133,8 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
132
133
  const setInputRef = useMemo(() => mergeRefs(inputRef, forwardedRef), [forwardedRef]);
133
134
  const [focused, setFocused] = useState(false);
134
135
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
136
+ const reactId = useId();
137
+ const inputDomId = `eh-textarea-${reactId.replace(/:/g, "")}`;
135
138
 
136
139
  const isControlled = typeof value === "string";
137
140
  const currentValue = isControlled ? value : uncontrolledValue;
@@ -141,28 +144,40 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
141
144
  const chrome = chromeFor(visualState);
142
145
  const resolvedAccessibilityLabel = accessibilityLabel ?? (showLabel ? undefined : label);
143
146
 
147
+ const focusInput = () => {
148
+ if (disabled) return;
149
+ inputRef.current?.focus();
150
+ };
151
+
144
152
  useApplyWebClassName(rootRef, className);
145
153
  useApplyWebClassName(inputRef, inputClassName);
146
154
 
147
155
  return (
148
156
  <View ref={rootRef} style={[styles.root, disabled && styles.disabled, style]}>
149
157
  {showLabel && label ? (
150
- <Text style={[styles.label, { color: chrome.labelColor }]}>{label}</Text>
158
+ <Pressable disabled={disabled} onPress={focusInput} style={styles.labelPressable}>
159
+ <Text style={[styles.label, { color: chrome.labelColor }]}>{label}</Text>
160
+ </Pressable>
151
161
  ) : null}
152
162
 
153
- <View
163
+ <Pressable
164
+ accessibilityRole="none"
165
+ disabled={disabled}
166
+ onPress={focusInput}
154
167
  style={[
155
168
  styles.field,
156
169
  {
157
170
  borderColor: chrome.borderColor,
158
171
  backgroundColor: chrome.backgroundColor,
159
172
  },
173
+ Platform.OS === "web" ? styles.fieldWeb : null,
160
174
  fieldStyle,
161
175
  ]}
162
176
  >
163
177
  <TextInput
164
178
  ref={setInputRef}
165
179
  {...textInputProps}
180
+ nativeID={inputDomId}
166
181
  multiline
167
182
  textAlignVertical="top"
168
183
  value={isControlled ? value : undefined}
@@ -193,7 +208,7 @@ export const Textarea = forwardRef<ComponentRef<typeof TextInput>, TextareaProps
193
208
  accessibilityLabel={resolvedAccessibilityLabel}
194
209
  accessibilityState={{ disabled }}
195
210
  />
196
- </View>
211
+ </Pressable>
197
212
 
198
213
  {showHint && hint ? (
199
214
  <Text style={[styles.hint, { color: chrome.hintColor }]}>{hint}</Text>
@@ -211,6 +226,9 @@ const styles = StyleSheet.create({
211
226
  disabled: {
212
227
  opacity: 0.6,
213
228
  },
229
+ labelPressable: {
230
+ alignSelf: "flex-start",
231
+ },
214
232
  label: {
215
233
  ...textareaTypography.label,
216
234
  },
@@ -223,14 +241,21 @@ const styles = StyleSheet.create({
223
241
  paddingLeft: FIELD_PADDING_HORIZONTAL,
224
242
  paddingRight: FIELD_PADDING_HORIZONTAL,
225
243
  },
244
+ fieldWeb: {
245
+ cursor: "text" as unknown as ViewStyle["cursor"],
246
+ },
226
247
  input: {
227
248
  flex: 1,
249
+ alignSelf: "stretch",
228
250
  paddingVertical: 0,
229
251
  margin: 0,
230
252
  },
231
253
  inputWeb: {
232
254
  outlineStyle: "none",
233
255
  resize: "none",
256
+ height: "100%",
257
+ minWidth: 0,
258
+ cursor: "text" as unknown as TextStyle["cursor"],
234
259
  } as TextStyle,
235
260
  inputAndroid: {
236
261
  includeFontPadding: false,
@@ -0,0 +1,46 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { fn } from "storybook/test";
3
+ import { View, StyleSheet } from "react-native";
4
+ import { ToggleRow } from "./ToggleRow";
5
+
6
+ const meta = {
7
+ title: "Components/ToggleRow",
8
+ component: ToggleRow,
9
+ args: {
10
+ label: "Bookings and digital key",
11
+ defaultValue: false,
12
+ onValueChange: fn(),
13
+ },
14
+ parameters: {
15
+ backgrounds: { default: "black" },
16
+ },
17
+ } satisfies Meta<typeof ToggleRow>;
18
+
19
+ export default meta;
20
+ type Story = StoryObj<typeof meta>;
21
+
22
+ export const Default: Story = {};
23
+
24
+ export const Active: Story = {
25
+ args: { defaultValue: true },
26
+ };
27
+
28
+ export const Stack: Story = {
29
+ render: () => (
30
+ <View style={styles.stack}>
31
+ <ToggleRow label="Bookings and digital key" defaultValue={false} onValueChange={fn()} />
32
+ <ToggleRow
33
+ label="Email notifications"
34
+ defaultValue={false}
35
+ onValueChange={fn()}
36
+ showBorder={false}
37
+ />
38
+ </View>
39
+ ),
40
+ };
41
+
42
+ const styles = StyleSheet.create({
43
+ stack: {
44
+ width: 462,
45
+ },
46
+ });