@ecohouse/ui 0.1.34 → 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.
@@ -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
  },
@@ -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,
@@ -41,7 +41,11 @@ export { Sidebar } from "./Sidebar";
41
41
  export type { SidebarProps, SidebarItem, SidebarUser } from "./Sidebar";
42
42
 
43
43
  export { LanguageSwitcher, defaultLanguageOptions } from "./LanguageSwitcher";
44
- export type { LanguageOption, LanguageSwitcherProps } from "./LanguageSwitcher";
44
+ export type {
45
+ LanguageOption,
46
+ LanguageSwitcherProps,
47
+ LanguageSwitcherVariant,
48
+ } from "./LanguageSwitcher";
45
49
 
46
50
  export { StatCard } from "./StatCard";
47
51
  export type { StatCardProps } from "./StatCard";
@@ -3,9 +3,9 @@ import { colors } from "../../theme";
3
3
  import type { IconProps } from "../types";
4
4
  import { GLOBE_PATH } from "./globePath";
5
5
 
6
- export function GlobeIcon({ size = 20, color = colors.primary, strokeWidth = 2 }: IconProps) {
6
+ export function GlobeIcon({ size = 16, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
7
7
  return (
8
- <Svg width={size} height={size} viewBox="0 0 20 20" fill="none">
8
+ <Svg width={size} height={size} viewBox="0 0 16 16" fill="none">
9
9
  <Path
10
10
  d={GLOBE_PATH}
11
11
  stroke={color}
@@ -2,12 +2,12 @@ import { colors } from "../../theme";
2
2
  import type { IconProps } from "../types";
3
3
  import { GLOBE_PATH } from "./globePath";
4
4
 
5
- export function GlobeIcon({ size = 20, color = colors.primary, strokeWidth = 2 }: IconProps) {
5
+ export function GlobeIcon({ size = 16, color = colors.primary, strokeWidth = 1.5 }: IconProps) {
6
6
  return (
7
7
  <svg
8
8
  width={size}
9
9
  height={size}
10
- viewBox="0 0 20 20"
10
+ viewBox="0 0 16 16"
11
11
  fill="none"
12
12
  xmlns="http://www.w3.org/2000/svg"
13
13
  aria-hidden
@@ -1,3 +1,3 @@
1
- /** Globe paths — 20×20 viewBox. */
1
+ /** Globe paths — 16×16 viewBox. */
2
2
  export const GLOBE_PATH =
3
- "M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333M18.3333 10C18.3333 5.39763 14.6024 1.66667 10 1.66667M18.3333 10H1.66667M10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10M10 18.3333C12.0833 16.0511 13.2675 13.0836 13.3333 10C13.2675 6.91643 12.0833 3.94892 10 1.66667M10 18.3333C7.91667 16.0511 6.73248 13.0836 6.66667 10C6.73248 6.91643 7.91667 3.94892 10 1.66667M1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667";
3
+ "M1.3335 8.00004H14.6668M1.3335 8.00004C1.3335 11.6819 4.31826 14.6667 8.00016 14.6667M1.3335 8.00004C1.3335 4.31814 4.31826 1.33337 8.00016 1.33337M14.6668 8.00004C14.6668 11.6819 11.6821 14.6667 8.00016 14.6667M14.6668 8.00004C14.6668 4.31814 11.6821 1.33337 8.00016 1.33337M8.00016 1.33337C9.66768 3.15894 10.6153 5.52806 10.6668 8.00004C10.6153 10.472 9.66768 12.8411 8.00016 14.6667M8.00016 1.33337C6.33264 3.15894 5.38499 5.52806 5.3335 8.00004C5.38499 10.472 6.33264 12.8411 8.00016 14.6667";
@@ -1,2 +1,2 @@
1
1
  export const KEY_PATH =
2
- "M5.28673 10.0002L7.0545 11.768M7.6435 7.64301L9.41127 9.41078M10.0003 5.28624L11.768 7.05401M4.22553 11.0604L11.0609 4.22505C11.7209 3.56502 12.0513 3.235 12.4318 3.11136C12.7666 3.00259 13.127 3.00259 13.4618 3.11136C13.8421 3.23494 14.1718 3.56464 14.8311 4.22393L15.775 5.16785C16.4351 5.82788 16.7654 6.15867 16.8891 6.53922C16.9979 6.87396 16.9976 7.23381 16.8889 7.56855C16.7652 7.9491 16.4353 8.27937 15.7753 8.9394L8.93989 15.7748C8.27986 16.4348 7.94959 16.7647 7.56904 16.8884C7.2343 16.9971 6.87445 16.9974 6.53971 16.8886C6.15916 16.765 5.82837 16.4346 5.16834 15.7745L4.22441 14.8306C3.56513 14.1713 3.23542 13.8416 3.11185 13.4613C3.00308 13.1266 3.00308 12.7661 3.11185 12.4313C3.23549 12.0508 3.56551 11.7204 4.22553 11.0604Z";
2
+ "M14.1668 8.33333V6.66667C14.1668 4.36548 12.3013 2.5 10.0002 2.5C7.69898 2.5 5.8335 4.36548 5.8335 6.66667V8.33333M10.0002 12.0833V13.75M7.3335 17.5H12.6668C14.067 17.5 14.767 17.5 15.3018 17.2275C15.7722 16.9878 16.1547 16.6054 16.3943 16.135C16.6668 15.6002 16.6668 14.9001 16.6668 13.5V12.3333C16.6668 10.9332 16.6668 10.2331 16.3943 9.69836C16.1547 9.22795 15.7722 8.8455 15.3018 8.60582C14.767 8.33333 14.067 8.33333 12.6668 8.33333H7.3335C5.93336 8.33333 5.2333 8.33333 4.69852 8.60582C4.22811 8.8455 3.84566 9.22795 3.60598 9.69836C3.3335 10.2331 3.3335 10.9332 3.3335 12.3333V13.5C3.3335 14.9001 3.3335 15.6002 3.60598 16.135C3.84566 16.6054 4.22811 16.9878 4.69852 17.2275C5.2333 17.5 5.93336 17.5 7.3335 17.5Z";
package/src/index.ts CHANGED
@@ -54,6 +54,7 @@ export type {
54
54
  SidebarUser,
55
55
  LanguageOption,
56
56
  LanguageSwitcherProps,
57
+ LanguageSwitcherVariant,
57
58
  StatCardProps,
58
59
  ButtonProps,
59
60
  ButtonSize,
@@ -47,13 +47,13 @@ export const segmentedToggleTypography = {
47
47
  letterSpacing: 0,
48
48
  } as const;
49
49
 
50
- /** Language switcher trigger and option labels — SemiBold 14. */
50
+ /** Language switcher trigger and option labels — SemiBold 14, tracking 3%. */
51
51
  export const languageSwitcherTypography = {
52
52
  fontFamily: fonts.sans,
53
53
  fontSize: 14,
54
54
  fontWeight: "600" as const,
55
- lineHeight: 19,
56
- letterSpacing: 0,
55
+ lineHeight: 14,
56
+ letterSpacing: 0.42,
57
57
  } as const;
58
58
 
59
59
  /** Counter value — Bold 14, line-height 100%, letter-spacing 0. */