@ecohouse/ui 0.1.34 → 0.1.36

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 (43) hide show
  1. package/README.md +1 -0
  2. package/dist/index.cjs +903 -489
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +169 -129
  5. package/dist/index.d.ts +169 -129
  6. package/dist/index.js +901 -491
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/components/AccordionItem/AccordionItem.tsx +14 -11
  10. package/src/components/AccountHeader/AccountHeader.tsx +16 -0
  11. package/src/components/DatePicker/DatePicker.tsx +47 -14
  12. package/src/components/Input/Input.tsx +54 -21
  13. package/src/components/LanguageSwitcher/LanguageSwitcher.stories.tsx +11 -0
  14. package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +103 -19
  15. package/src/components/LanguageSwitcher/index.ts +5 -1
  16. package/src/components/SegmentedToggle/SegmentedToggle.stories.tsx +9 -0
  17. package/src/components/SegmentedToggle/SegmentedToggle.tsx +19 -13
  18. package/src/components/Select/Select.tsx +90 -5
  19. package/src/components/Textarea/Textarea.tsx +29 -4
  20. package/src/components/index.ts +5 -1
  21. package/src/icons/Globe/GlobeIcon.tsx +2 -2
  22. package/src/icons/Globe/GlobeIcon.web.tsx +2 -2
  23. package/src/icons/Globe/globePath.ts +2 -2
  24. package/src/icons/Key/keyPath.ts +1 -1
  25. package/src/icons/MoreHorizontal/MoreHorizontalIcon.tsx +26 -0
  26. package/src/icons/MoreHorizontal/MoreHorizontalIcon.web.tsx +25 -0
  27. package/src/icons/MoreHorizontal/index.ts +1 -0
  28. package/src/icons/MoreHorizontal/moreHorizontalPath.ts +2 -0
  29. package/src/icons/TableView/TableViewIcon.tsx +20 -0
  30. package/src/icons/TableView/TableViewIcon.web.tsx +26 -0
  31. package/src/icons/TableView/index.ts +1 -0
  32. package/src/icons/TableView/tableViewPath.ts +2 -0
  33. package/src/icons/index.ts +2 -0
  34. package/src/icons/registry.ts +6 -0
  35. package/src/index.ts +7 -0
  36. package/src/primitives/IconStatusBadge/IconStatusBadge.stories.tsx +21 -0
  37. package/src/primitives/IconStatusBadge/IconStatusBadge.tsx +76 -0
  38. package/src/primitives/IconStatusBadge/index.ts +2 -0
  39. package/src/primitives/InfoCardV2/InfoCardV2.stories.tsx +64 -0
  40. package/src/primitives/InfoCardV2/InfoCardV2.tsx +116 -0
  41. package/src/primitives/InfoCardV2/index.ts +2 -0
  42. package/src/primitives/index.ts +7 -0
  43. package/src/theme/typography.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecohouse/ui",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Cross-platform presentation-only UI components for EcoHouse (React Native + React Native Web)",
@@ -10,6 +10,7 @@ import {
10
10
  import {
11
11
  Animated,
12
12
  Easing,
13
+ Platform,
13
14
  Pressable,
14
15
  StyleSheet,
15
16
  Text,
@@ -119,9 +120,13 @@ export const AccordionItem = forwardRef<ComponentRef<typeof View>, AccordionItem
119
120
  const bodyTextStyle = [styles.body, { fontSize: bodyFontSize, lineHeight: bodyLineHeight }];
120
121
 
121
122
  return (
122
- <View
123
+ <Pressable
123
124
  {...rest}
124
125
  ref={setContainerRef}
126
+ accessibilityRole="button"
127
+ accessibilityState={{ expanded: open }}
128
+ accessibilityLabel={accessibilityLabel ?? title}
129
+ onPress={toggle}
125
130
  style={[
126
131
  styles.root,
127
132
  {
@@ -129,16 +134,11 @@ export const AccordionItem = forwardRef<ComponentRef<typeof View>, AccordionItem
129
134
  paddingRight: paddingX,
130
135
  paddingLeft: paddingX,
131
136
  },
137
+ Platform.OS === "web" ? styles.rootWeb : null,
132
138
  style,
133
139
  ]}
134
140
  >
135
- <Pressable
136
- accessibilityRole="button"
137
- accessibilityState={{ expanded: open }}
138
- accessibilityLabel={accessibilityLabel ?? title}
139
- onPress={toggle}
140
- style={styles.header}
141
- >
141
+ <View style={styles.header}>
142
142
  <Text style={styles.title}>{title}</Text>
143
143
  <Animated.View style={[styles.chevron, { transform: [{ rotate: chevronRotate }] }]}>
144
144
  <ChevronDownIcon
@@ -147,7 +147,7 @@ export const AccordionItem = forwardRef<ComponentRef<typeof View>, AccordionItem
147
147
  strokeWidth={2}
148
148
  />
149
149
  </Animated.View>
150
- </Pressable>
150
+ </View>
151
151
 
152
152
  <View
153
153
  style={[styles.measureHidden, { left: paddingX, right: paddingX }]}
@@ -157,12 +157,12 @@ export const AccordionItem = forwardRef<ComponentRef<typeof View>, AccordionItem
157
157
  <Text style={bodyTextStyle}>{children}</Text>
158
158
  </View>
159
159
 
160
- <Animated.View style={[styles.bodyClip, { height: bodyHeight }]}>
160
+ <Animated.View style={[styles.bodyClip, { height: bodyHeight }]} pointerEvents="none">
161
161
  <View style={styles.bodyInner}>
162
162
  <Text style={bodyTextStyle}>{children}</Text>
163
163
  </View>
164
164
  </Animated.View>
165
- </View>
165
+ </Pressable>
166
166
  );
167
167
  },
168
168
  );
@@ -176,6 +176,9 @@ const styles = StyleSheet.create({
176
176
  opacity: 1,
177
177
  overflow: "hidden",
178
178
  },
179
+ rootWeb: {
180
+ cursor: "pointer" as unknown as ViewStyle["cursor"],
181
+ },
179
182
  header: {
180
183
  flexDirection: "row",
181
184
  alignItems: "center",
@@ -24,6 +24,8 @@ export interface AccountHeaderProps extends Omit<ViewProps, "style" | "children"
24
24
  /** When set, shows the 28×28 menu control (mobile account shell). Ignored if `onBackPress` is set. */
25
25
  onMenuPress?: () => void;
26
26
  onNotificationsPress?: () => void;
27
+ /** Optional control rendered to the left of notifications (e.g. language switcher). */
28
+ languageSlot?: ReactNode;
27
29
  /** Optional status chip (or other accessory) rendered after the title with a 12px gap. */
28
30
  badge?: ReactNode;
29
31
  /** Accessible name for the back control. Defaults to `"Back"`. */
@@ -65,6 +67,7 @@ export const AccountHeader = forwardRef<ComponentRef<typeof View>, AccountHeader
65
67
  onBackPress,
66
68
  onMenuPress,
67
69
  onNotificationsPress,
70
+ languageSlot,
68
71
  badge,
69
72
  backAccessibilityLabel = "Back",
70
73
  menuAccessibilityLabel = "Open menu",
@@ -141,6 +144,7 @@ export const AccountHeader = forwardRef<ComponentRef<typeof View>, AccountHeader
141
144
  </View>
142
145
 
143
146
  <View style={styles.end}>
147
+ {languageSlot != null ? <View style={styles.languageSlot}>{languageSlot}</View> : null}
144
148
  <Pressable
145
149
  accessibilityRole="button"
146
150
  accessibilityLabel={notificationsAccessibilityLabel}
@@ -171,6 +175,8 @@ const styles = StyleSheet.create({
171
175
  backgroundColor: colors.dark,
172
176
  borderBottomWidth: 1,
173
177
  borderBottomColor: colors.grey700,
178
+ zIndex: 40,
179
+ position: "relative",
174
180
  },
175
181
  start: {
176
182
  flex: 1,
@@ -194,6 +200,16 @@ const styles = StyleSheet.create({
194
200
  end: {
195
201
  flexShrink: 0,
196
202
  marginLeft: RIGHT_GAP,
203
+ flexDirection: "row",
204
+ alignItems: "center",
205
+ gap: 8,
206
+ zIndex: 50,
207
+ position: "relative",
208
+ },
209
+ languageSlot: {
210
+ flexShrink: 0,
211
+ zIndex: 50,
212
+ position: "relative",
197
213
  },
198
214
  iconButton: {
199
215
  width: ICON_BUTTON_SIZE,
@@ -121,14 +121,22 @@ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
121
121
 
122
122
  const DEFAULT_WIDTH = 320;
123
123
  const DEFAULT_MENU_HEIGHT = 388;
124
+ const MENU_CONTENT_WIDTH = 296;
124
125
  const RANGE_TRIGGER_WIDTH = 382;
125
126
  const TRIGGER_HEIGHT = 44;
126
127
  const TRIGGER_RADIUS = 60;
127
128
  const MENU_RADIUS = 20;
129
+ const MENU_PADDING = 12;
128
130
  const ICON_SIZE = 20;
129
131
  const NAV_ICON_SIZE = 20;
132
+ const HEADER_HEIGHT = 36;
133
+ const WEEKDAY_ROW_HEIGHT = 44;
134
+ const WEEKDAY_ROW_GAP = 8;
135
+ const WEEK_ROW_GAP = 16;
136
+ const DAY_CELL_WIDTH = 42.28571701049805;
130
137
  const DAY_CELL_HEIGHT = 44;
131
- const DAY_CIRCLE_SIZE = 44;
138
+ const DAY_CELL_RADIUS = 67;
139
+ const DAY_CELL_PADDING = 12;
132
140
 
133
141
  function RangeTriggerField({
134
142
  label,
@@ -508,7 +516,7 @@ export const DatePicker = forwardRef<ComponentRef<typeof View>, DatePickerProps>
508
516
  <View>
509
517
  <View style={styles.weekdayRow}>
510
518
  {weekdayLabels.map((weekdayLabel, index) => (
511
- <View key={`${weekdayLabel}-${index}`} style={styles.dayCellWrap}>
519
+ <View key={`${weekdayLabel}-${index}`} style={styles.weekdayCell}>
512
520
  <Text style={styles.weekdayText}>{weekdayLabel}</Text>
513
521
  </View>
514
522
  ))}
@@ -756,10 +764,11 @@ const styles = StyleSheet.create({
756
764
  marginTop: 8,
757
765
  zIndex: 10,
758
766
  borderRadius: MENU_RADIUS,
759
- padding: 12,
767
+ padding: MENU_PADDING,
760
768
  gap: 0,
761
769
  opacity: 1,
762
770
  backgroundColor: colors.grey700,
771
+ overflow: "hidden",
763
772
  ...Platform.select({
764
773
  web: {
765
774
  boxShadow: `0 8px 24px ${colors.black}73`,
@@ -788,14 +797,17 @@ const styles = StyleSheet.create({
788
797
  width: RANGE_TRIGGER_WIDTH,
789
798
  },
790
799
  calendarHeader: {
791
- height: 36,
800
+ height: HEADER_HEIGHT,
801
+ paddingTop: 8,
802
+ paddingBottom: 8,
792
803
  flexDirection: "row",
793
804
  alignItems: "center",
794
805
  justifyContent: "space-between",
806
+ opacity: 1,
795
807
  },
796
808
  navButton: {
797
- width: 36,
798
- height: 36,
809
+ width: HEADER_HEIGHT,
810
+ height: HEADER_HEIGHT,
799
811
  alignItems: "center",
800
812
  justifyContent: "center",
801
813
  },
@@ -808,7 +820,21 @@ const styles = StyleSheet.create({
808
820
  textAlign: "center",
809
821
  },
810
822
  weekdayRow: {
823
+ width: MENU_CONTENT_WIDTH,
824
+ height: WEEKDAY_ROW_HEIGHT,
825
+ marginTop: WEEKDAY_ROW_GAP,
811
826
  flexDirection: "row",
827
+ alignItems: "center",
828
+ opacity: 1,
829
+ },
830
+ weekdayCell: {
831
+ width: DAY_CELL_WIDTH,
832
+ height: WEEKDAY_ROW_HEIGHT,
833
+ flexGrow: 1,
834
+ flexShrink: 1,
835
+ flexBasis: 0,
836
+ alignItems: "center",
837
+ justifyContent: "center",
812
838
  },
813
839
  weekdayText: {
814
840
  ...datePickerTypography.weekday,
@@ -817,29 +843,36 @@ const styles = StyleSheet.create({
817
843
  textTransform: "uppercase",
818
844
  },
819
845
  weekRow: {
846
+ width: MENU_CONTENT_WIDTH,
820
847
  flexDirection: "row",
821
848
  position: "relative",
822
849
  },
823
850
  weekRowSpaced: {
824
- marginTop: 8,
851
+ marginTop: WEEK_ROW_GAP,
825
852
  },
826
853
  rangeHighlight: {
827
854
  position: "absolute",
828
- top: (DAY_CELL_HEIGHT - DAY_CIRCLE_SIZE) / 2,
829
- bottom: (DAY_CELL_HEIGHT - DAY_CIRCLE_SIZE) / 2,
830
- borderRadius: DAY_CIRCLE_SIZE / 2,
855
+ top: 0,
856
+ bottom: 0,
857
+ borderRadius: DAY_CELL_RADIUS,
831
858
  backgroundColor: colors.primaryHover,
832
859
  },
833
860
  dayCellWrap: {
834
- flex: 1,
861
+ width: DAY_CELL_WIDTH,
835
862
  height: DAY_CELL_HEIGHT,
863
+ flexGrow: 1,
864
+ flexShrink: 1,
865
+ flexBasis: 0,
836
866
  alignItems: "center",
837
867
  justifyContent: "center",
868
+ opacity: 1,
838
869
  },
839
870
  dayCircle: {
840
- width: DAY_CIRCLE_SIZE,
841
- height: DAY_CIRCLE_SIZE,
842
- borderRadius: DAY_CIRCLE_SIZE / 2,
871
+ width: "100%",
872
+ height: "100%",
873
+ borderRadius: DAY_CELL_RADIUS,
874
+ padding: DAY_CELL_PADDING,
875
+ gap: 10,
843
876
  alignItems: "center",
844
877
  justifyContent: "center",
845
878
  },
@@ -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,
@@ -183,6 +184,8 @@ export const Input = forwardRef<ComponentRef<typeof TextInput>, InputProps>(func
183
184
  const [focused, setFocused] = useState(false);
184
185
  const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue ?? "");
185
186
  const metrics = sizeConfig[size];
187
+ const reactId = useId();
188
+ const inputDomId = `eh-input-${reactId.replace(/:/g, "")}`;
186
189
 
187
190
  const isControlled = typeof value === "string";
188
191
  const currentValue = isControlled ? value : uncontrolledValue;
@@ -204,31 +207,44 @@ export const Input = forwardRef<ComponentRef<typeof TextInput>, InputProps>(func
204
207
  color: chrome.rightIconColor,
205
208
  });
206
209
 
210
+ const focusInput = () => {
211
+ if (disabled) return;
212
+ inputRef.current?.focus();
213
+ };
214
+
207
215
  useApplyWebClassName(rootRef, className);
208
216
  useApplyWebClassName(inputRef, inputClassName);
209
217
 
218
+ const fieldChromeStyle = [
219
+ styles.field,
220
+ {
221
+ height: metrics.height,
222
+ borderRadius: metrics.borderRadius,
223
+ paddingTop: metrics.paddingVertical,
224
+ paddingBottom: metrics.paddingVertical,
225
+ paddingLeft: metrics.paddingHorizontal,
226
+ paddingRight: metrics.paddingHorizontal,
227
+ gap: metrics.gap,
228
+ borderColor: chrome.borderColor,
229
+ backgroundColor: chrome.backgroundColor,
230
+ },
231
+ Platform.OS === "web" ? styles.fieldWeb : null,
232
+ fieldStyle,
233
+ ];
234
+
210
235
  return (
211
236
  <View ref={rootRef} style={[styles.root, disabled && styles.disabled, style]}>
212
237
  {showLabel && label ? (
213
- <Text style={[styles.label, { color: chrome.labelColor }]}>{label}</Text>
238
+ <Pressable disabled={disabled} onPress={focusInput} style={styles.labelPressable}>
239
+ <Text style={[styles.label, { color: chrome.labelColor }]}>{label}</Text>
240
+ </Pressable>
214
241
  ) : null}
215
242
 
216
- <View
217
- style={[
218
- styles.field,
219
- {
220
- height: metrics.height,
221
- borderRadius: metrics.borderRadius,
222
- paddingTop: metrics.paddingVertical,
223
- paddingBottom: metrics.paddingVertical,
224
- paddingLeft: metrics.paddingHorizontal,
225
- paddingRight: metrics.paddingHorizontal,
226
- gap: metrics.gap,
227
- borderColor: chrome.borderColor,
228
- backgroundColor: chrome.backgroundColor,
229
- },
230
- fieldStyle,
231
- ]}
243
+ <Pressable
244
+ accessibilityRole="none"
245
+ disabled={disabled}
246
+ onPress={focusInput}
247
+ style={fieldChromeStyle}
232
248
  >
233
249
  {hasLeftIcon ? (
234
250
  <View style={[styles.iconSlot, { width: metrics.iconSize, height: metrics.iconSize }]}>
@@ -239,6 +255,7 @@ export const Input = forwardRef<ComponentRef<typeof TextInput>, InputProps>(func
239
255
  <TextInput
240
256
  ref={setInputRef}
241
257
  {...textInputProps}
258
+ nativeID={inputDomId}
242
259
  value={isControlled ? value : undefined}
243
260
  defaultValue={isControlled ? undefined : defaultValue}
244
261
  editable={!disabled}
@@ -281,12 +298,17 @@ export const Input = forwardRef<ComponentRef<typeof TextInput>, InputProps>(func
281
298
  {rightIconNode}
282
299
  </TouchableOpacity>
283
300
  ) : (
284
- <View style={[styles.iconSlot, { width: metrics.iconSize, height: metrics.iconSize }]}>
301
+ <Pressable
302
+ accessibilityRole="none"
303
+ disabled={disabled}
304
+ onPress={focusInput}
305
+ style={[styles.iconSlot, { width: metrics.iconSize, height: metrics.iconSize }]}
306
+ >
285
307
  {rightIconNode}
286
- </View>
308
+ </Pressable>
287
309
  )
288
310
  ) : null}
289
- </View>
311
+ </Pressable>
290
312
 
291
313
  {showHint && hint ? (
292
314
  <Text style={[styles.hint, { color: chrome.hintColor }]}>{hint}</Text>
@@ -304,6 +326,9 @@ const styles = StyleSheet.create({
304
326
  disabled: {
305
327
  opacity: 0.6,
306
328
  },
329
+ labelPressable: {
330
+ alignSelf: "flex-start",
331
+ },
307
332
  label: {
308
333
  ...inputTypography.label,
309
334
  },
@@ -312,13 +337,21 @@ const styles = StyleSheet.create({
312
337
  flexDirection: "row",
313
338
  alignItems: "center",
314
339
  },
340
+ fieldWeb: {
341
+ // Web-only cursor for the clickable field chrome.
342
+ cursor: "text" as unknown as ViewStyle["cursor"],
343
+ },
315
344
  input: {
316
345
  flex: 1,
346
+ alignSelf: "stretch",
317
347
  paddingVertical: 0,
318
348
  margin: 0,
319
349
  },
320
350
  inputWeb: {
321
351
  outlineStyle: "none",
352
+ height: "100%",
353
+ minWidth: 0,
354
+ cursor: "text" as unknown as TextStyle["cursor"],
322
355
  } as TextStyle,
323
356
  inputAndroid: {
324
357
  includeFontPadding: false,
@@ -50,3 +50,14 @@ export const CustomLanguages: Story = {
50
50
  </View>
51
51
  ),
52
52
  };
53
+
54
+ export const IconVariant: Story = {
55
+ args: { variant: "icon", defaultOpen: true },
56
+ decorators: [
57
+ (Story) => (
58
+ <View style={{ alignItems: "flex-end", padding: 24, minHeight: 200 }}>
59
+ <Story />
60
+ </View>
61
+ ),
62
+ ],
63
+ };
@@ -30,6 +30,8 @@ export interface LanguageOption {
30
30
  label: string;
31
31
  }
32
32
 
33
+ export type LanguageSwitcherVariant = "default" | "icon";
34
+
33
35
  export interface LanguageSwitcherProps extends Omit<ViewProps, "style" | "children"> {
34
36
  options?: readonly LanguageOption[];
35
37
  value?: string;
@@ -39,6 +41,11 @@ export interface LanguageSwitcherProps extends Omit<ViewProps, "style" | "childr
39
41
  defaultOpen?: boolean;
40
42
  onOpenChange?: (open: boolean) => void;
41
43
  disabled?: boolean;
44
+ /**
45
+ * `default` — globe + label + chevron (marketing header).
46
+ * `icon` — icon-only control matching account header action buttons.
47
+ */
48
+ variant?: LanguageSwitcherVariant;
42
49
  accessibilityLabel?: string;
43
50
  style?: StyleProp<ViewStyle>;
44
51
  className?: string;
@@ -54,6 +61,13 @@ export const defaultLanguageOptions = [
54
61
  const CONTROL_WIDTH = 90;
55
62
  const CONTROL_HEIGHT = 35;
56
63
  const ICON_SIZE = 16;
64
+ const ICON_BUTTON_SIZE = 32;
65
+ const ICON_MENU_WIDTH = 67;
66
+ const ICON_MENU_HEIGHT = 129;
67
+ const ICON_MENU_TOP = 39;
68
+ const ITEM_HEIGHT = 35;
69
+ /** Active option wash from design (`#E38E5E` at 7%). */
70
+ const ACTIVE_ITEM_BACKGROUND = "#E38E5E12";
57
71
 
58
72
  export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSwitcherProps>(
59
73
  function LanguageSwitcher(
@@ -66,6 +80,7 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
66
80
  defaultOpen = false,
67
81
  onOpenChange,
68
82
  disabled = false,
83
+ variant = "default",
69
84
  accessibilityLabel = "Language",
70
85
  style,
71
86
  className,
@@ -88,6 +103,7 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
88
103
  const isOpen = isOpenControlled ? openProp : uncontrolledOpen;
89
104
  const selectedOption =
90
105
  options.find((option) => option.value === selectedValue) ?? options[0] ?? null;
106
+ const isIconVariant = variant === "icon";
91
107
 
92
108
  useApplyWebClassName(wrapperRef, className?.trim() || undefined);
93
109
 
@@ -147,7 +163,14 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
147
163
  <View
148
164
  {...rest}
149
165
  ref={setWrapperRef}
150
- style={[styles.wrapper, isOpen && styles.wrapperOpen, disabled && styles.disabled, style]}
166
+ style={[
167
+ styles.wrapper,
168
+ isIconVariant ? styles.wrapperIcon : styles.wrapperDefault,
169
+ isOpen && styles.wrapperOpen,
170
+ isOpen && isIconVariant && styles.wrapperOpenIcon,
171
+ disabled && styles.disabled,
172
+ style,
173
+ ]}
151
174
  >
152
175
  <Pressable
153
176
  ref={triggerRef}
@@ -160,21 +183,28 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
160
183
  onHoverIn={() => setTriggerHovered(true)}
161
184
  onHoverOut={() => setTriggerHovered(false)}
162
185
  style={({ pressed }) => [
163
- styles.trigger,
164
- (triggerHovered || pressed || isOpen) && styles.triggerActive,
186
+ isIconVariant ? styles.triggerIcon : styles.trigger,
187
+ !isIconVariant && (triggerHovered || pressed || isOpen) && styles.triggerActive,
165
188
  ]}
166
189
  >
167
- <GlobeIcon size={ICON_SIZE} color={colors.white} strokeWidth={2} />
168
- <Text style={styles.triggerLabel} numberOfLines={1}>
169
- {selectedOption?.label ?? ""}
170
- </Text>
171
- <View style={[styles.chevron, isOpen && styles.chevronOpen]}>
172
- <ChevronDownIcon size={ICON_SIZE} color={colors.white} strokeWidth={2} />
173
- </View>
190
+ <GlobeIcon size={ICON_SIZE} color={colors.white} strokeWidth={isIconVariant ? 1.5 : 2} />
191
+ {!isIconVariant ? (
192
+ <>
193
+ <Text style={styles.triggerLabel} numberOfLines={1}>
194
+ {selectedOption?.label ?? ""}
195
+ </Text>
196
+ <View style={[styles.chevron, isOpen && styles.chevronOpen]}>
197
+ <ChevronDownIcon size={ICON_SIZE} color={colors.white} strokeWidth={2} />
198
+ </View>
199
+ </>
200
+ ) : null}
174
201
  </Pressable>
175
202
 
176
203
  {isOpen && options.length > 0 ? (
177
- <View style={styles.menu} accessibilityRole="menu">
204
+ <View
205
+ style={[styles.menu, isIconVariant ? styles.menuIcon : styles.menuDefault]}
206
+ accessibilityRole="menu"
207
+ >
178
208
  {options.map((option) => {
179
209
  const selected = option.value === selectedValue;
180
210
  return (
@@ -188,12 +218,21 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
188
218
  onHoverOut={() => setHoveredValue(null)}
189
219
  style={({ pressed }) => [
190
220
  styles.item,
221
+ isIconVariant ? styles.itemIcon : null,
191
222
  selected
192
- ? styles.itemSelected
223
+ ? isIconVariant
224
+ ? styles.itemSelectedIcon
225
+ : styles.itemSelected
193
226
  : (hoveredValue === option.value || pressed) && styles.itemHovered,
194
227
  ]}
195
228
  >
196
- <Text style={[styles.itemLabel, selected && styles.itemLabelSelected]}>
229
+ <Text
230
+ style={[
231
+ styles.itemLabel,
232
+ selected &&
233
+ (isIconVariant ? styles.itemLabelSelectedIcon : styles.itemLabelSelected),
234
+ ]}
235
+ >
197
236
  {option.label}
198
237
  </Text>
199
238
  </Pressable>
@@ -209,11 +248,19 @@ export const LanguageSwitcher = forwardRef<ComponentRef<typeof View>, LanguageSw
209
248
  const styles = StyleSheet.create({
210
249
  wrapper: {
211
250
  position: "relative",
212
- width: CONTROL_WIDTH,
213
251
  alignSelf: "flex-start",
214
252
  },
253
+ wrapperDefault: {
254
+ width: CONTROL_WIDTH,
255
+ },
256
+ wrapperIcon: {
257
+ width: ICON_BUTTON_SIZE,
258
+ },
215
259
  wrapperOpen: {
216
- zIndex: 20,
260
+ zIndex: 100,
261
+ },
262
+ wrapperOpenIcon: {
263
+ zIndex: 200,
217
264
  },
218
265
  disabled: {
219
266
  opacity: 0.6,
@@ -228,6 +275,17 @@ const styles = StyleSheet.create({
228
275
  borderRadius: 12,
229
276
  backgroundColor: "transparent",
230
277
  },
278
+ triggerIcon: {
279
+ width: ICON_BUTTON_SIZE,
280
+ height: ICON_BUTTON_SIZE,
281
+ padding: 8,
282
+ alignItems: "center",
283
+ justifyContent: "center",
284
+ borderRadius: 5,
285
+ backgroundColor: colors.grey800,
286
+ borderWidth: 1,
287
+ borderColor: colors.grey700,
288
+ },
231
289
  triggerActive: {
232
290
  backgroundColor: colors.whiteAlpha6,
233
291
  },
@@ -250,22 +308,42 @@ const styles = StyleSheet.create({
250
308
  },
251
309
  menu: {
252
310
  position: "absolute",
311
+ padding: 8,
312
+ borderRadius: 12,
313
+ overflow: "hidden",
314
+ },
315
+ menuDefault: {
253
316
  top: CONTROL_HEIGHT + 8,
254
317
  left: 0,
255
318
  width: CONTROL_WIDTH,
256
- padding: 8,
257
319
  gap: 8,
258
- borderRadius: 12,
259
320
  backgroundColor: colors.whiteAlpha6,
260
- overflow: "hidden",
321
+ },
322
+ menuIcon: {
323
+ top: ICON_MENU_TOP,
324
+ left: (ICON_BUTTON_SIZE - ICON_MENU_WIDTH) / 2,
325
+ right: "auto",
326
+ width: ICON_MENU_WIDTH,
327
+ height: ICON_MENU_HEIGHT,
328
+ gap: 4,
329
+ opacity: 1,
330
+ zIndex: 200,
331
+ backgroundColor: colors.grey800,
332
+ borderWidth: 1,
333
+ borderColor: colors.grey700,
261
334
  },
262
335
  item: {
263
- height: CONTROL_HEIGHT,
336
+ height: ITEM_HEIGHT,
264
337
  alignItems: "center",
265
338
  justifyContent: "center",
266
339
  paddingVertical: 8,
267
340
  paddingHorizontal: 12,
268
341
  borderRadius: 8,
342
+ gap: 8,
343
+ },
344
+ itemIcon: {
345
+ width: 51,
346
+ alignSelf: "stretch",
269
347
  },
270
348
  itemHovered: {
271
349
  backgroundColor: colors.whiteAlpha6,
@@ -273,6 +351,9 @@ const styles = StyleSheet.create({
273
351
  itemSelected: {
274
352
  backgroundColor: colors.whiteAlpha6,
275
353
  },
354
+ itemSelectedIcon: {
355
+ backgroundColor: ACTIVE_ITEM_BACKGROUND,
356
+ },
276
357
  itemLabel: {
277
358
  ...languageSwitcherTypography,
278
359
  ...Platform.select({ web: { fontFamily: `${fonts.sans}, sans-serif` } }),
@@ -281,4 +362,7 @@ const styles = StyleSheet.create({
281
362
  itemLabelSelected: {
282
363
  color: colors.primary,
283
364
  },
365
+ itemLabelSelectedIcon: {
366
+ color: colors.primary,
367
+ },
284
368
  });
@@ -1,2 +1,6 @@
1
1
  export { LanguageSwitcher, defaultLanguageOptions } from "./LanguageSwitcher";
2
- export type { LanguageOption, LanguageSwitcherProps } from "./LanguageSwitcher";
2
+ export type {
3
+ LanguageOption,
4
+ LanguageSwitcherProps,
5
+ LanguageSwitcherVariant,
6
+ } from "./LanguageSwitcher";
@@ -52,6 +52,15 @@ type Story = StoryObj<typeof meta>;
52
52
 
53
53
  export const WithIcons: Story = {};
54
54
 
55
+ export const IconOnly: Story = {
56
+ args: {
57
+ options: [
58
+ { value: "grid", label: "Grid view", icon: LayoutGridIcon, showLabel: false },
59
+ { value: "list", label: "List view", icon: ListViewIcon, showLabel: false },
60
+ ],
61
+ },
62
+ };
63
+
55
64
  export const WithoutIcons: Story = {
56
65
  args: { options: optionsWithoutIcons, defaultValue: "first" },
57
66
  };