@korsolutions/ui 0.0.19 → 0.0.21

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 (53) hide show
  1. package/dist/components/index.d.mts +73 -4
  2. package/dist/components/index.mjs +156 -8
  3. package/dist/hooks/index.d.mts +32 -2
  4. package/dist/hooks/index.mjs +79 -2
  5. package/dist/{index-BLsiF42Z.d.mts → index-pCM7YTs1.d.mts} +165 -9
  6. package/dist/index.d.mts +3 -3
  7. package/dist/index.mjs +3 -2
  8. package/dist/primitives/index.d.mts +3 -2
  9. package/dist/primitives/index.mjs +3 -2
  10. package/dist/{primitives-CyDqzNcp.mjs → primitives-DNeYBN-3.mjs} +330 -12
  11. package/dist/{toast-manager-BOORCQn8.mjs → toast-manager-BfoJ-_dB.mjs} +1 -1
  12. package/dist/use-numeric-mask-B9WZG25o.d.mts +33 -0
  13. package/dist/use-numeric-mask-BQlz1Pus.mjs +113 -0
  14. package/dist/use-relative-position-BTKEyT1F.mjs +106 -0
  15. package/dist/use-relative-position-DBzhrBU7.d.mts +61 -0
  16. package/package.json +1 -1
  17. package/src/components/button/button.tsx +7 -4
  18. package/src/components/dropdown-menu/dropdown-menu.tsx +49 -0
  19. package/src/components/dropdown-menu/variants/default.tsx +40 -0
  20. package/src/components/dropdown-menu/variants/index.ts +5 -0
  21. package/src/components/index.ts +3 -1
  22. package/src/components/input/index.ts +2 -0
  23. package/src/components/input/numeric-input.tsx +73 -0
  24. package/src/components/popover/popover.tsx +51 -0
  25. package/src/components/popover/variants/default.tsx +26 -0
  26. package/src/components/popover/variants/index.ts +5 -0
  27. package/src/hooks/index.ts +4 -1
  28. package/src/hooks/use-currency-mask.ts +141 -0
  29. package/src/hooks/use-numeric-mask.ts +202 -0
  30. package/src/hooks/use-relative-position.ts +188 -0
  31. package/src/primitives/button/button-root.tsx +2 -4
  32. package/src/primitives/dropdown-menu/context.ts +25 -0
  33. package/src/primitives/dropdown-menu/dropdown-menu-button.tsx +47 -0
  34. package/src/primitives/dropdown-menu/dropdown-menu-content.tsx +39 -0
  35. package/src/primitives/dropdown-menu/dropdown-menu-divider.tsx +18 -0
  36. package/src/primitives/dropdown-menu/dropdown-menu-overlay.tsx +29 -0
  37. package/src/primitives/dropdown-menu/dropdown-menu-portal.tsx +21 -0
  38. package/src/primitives/dropdown-menu/dropdown-menu-root.tsx +35 -0
  39. package/src/primitives/dropdown-menu/dropdown-menu-trigger.tsx +47 -0
  40. package/src/primitives/dropdown-menu/index.ts +26 -0
  41. package/src/primitives/dropdown-menu/types.ts +13 -0
  42. package/src/primitives/index.ts +2 -0
  43. package/src/primitives/popover/context.ts +25 -0
  44. package/src/primitives/popover/index.ts +24 -0
  45. package/src/primitives/popover/popover-close.tsx +29 -0
  46. package/src/primitives/popover/popover-content.tsx +39 -0
  47. package/src/primitives/popover/popover-overlay.tsx +37 -0
  48. package/src/primitives/popover/popover-portal.tsx +21 -0
  49. package/src/primitives/popover/popover-root.tsx +35 -0
  50. package/src/primitives/popover/popover-trigger.tsx +47 -0
  51. package/src/primitives/popover/types.ts +7 -0
  52. package/src/utils/get-ref-layout.ts +16 -0
  53. /package/src/hooks/{useScreenSize.ts → use-screen-size.ts} +0 -0
@@ -0,0 +1,202 @@
1
+ import { useState, useCallback } from "react";
2
+
3
+ export type NumericMaskFormat = "currency" | "decimal" | "integer" | "percentage";
4
+
5
+ export interface UseNumericMaskOptions {
6
+ format?: NumericMaskFormat;
7
+ locale?: string;
8
+ currency?: string;
9
+ precision?: number;
10
+ min?: number;
11
+ max?: number;
12
+ allowNegative?: boolean;
13
+ onChange?: (value: number | null) => void;
14
+ }
15
+
16
+ export interface UseNumericMaskReturn {
17
+ value: string;
18
+ numericValue: number | null;
19
+ onChangeText: (text: string) => void;
20
+ onBlur: () => void;
21
+ onFocus: () => void;
22
+ keyboardType: "numeric" | "decimal-pad" | "number-pad";
23
+ setValue: (value: number | null) => void;
24
+ }
25
+
26
+ export function useNumericMask({
27
+ format = "decimal",
28
+ locale = "en-US",
29
+ currency = "USD",
30
+ precision = 2,
31
+ min,
32
+ max,
33
+ allowNegative = true,
34
+ onChange,
35
+ }: UseNumericMaskOptions = {}): UseNumericMaskReturn {
36
+ const [numericValue, setNumericValue] = useState<number | null>(null);
37
+ const [displayValue, setDisplayValue] = useState("");
38
+ const [isFocused, setIsFocused] = useState(false);
39
+
40
+ // Determine actual precision based on format
41
+ const effectivePrecision = format === "integer" ? 0 : precision;
42
+
43
+ const formatValue = useCallback(
44
+ (num: number | null): string => {
45
+ if (num === null || isNaN(num)) return "";
46
+
47
+ switch (format) {
48
+ case "currency":
49
+ return new Intl.NumberFormat(locale, {
50
+ style: "currency",
51
+ currency,
52
+ minimumFractionDigits: effectivePrecision,
53
+ maximumFractionDigits: effectivePrecision,
54
+ }).format(num);
55
+
56
+ case "percentage":
57
+ return new Intl.NumberFormat(locale, {
58
+ style: "percent",
59
+ minimumFractionDigits: effectivePrecision,
60
+ maximumFractionDigits: effectivePrecision,
61
+ }).format(num / 100);
62
+
63
+ case "integer":
64
+ return new Intl.NumberFormat(locale, {
65
+ minimumFractionDigits: 0,
66
+ maximumFractionDigits: 0,
67
+ }).format(num);
68
+
69
+ case "decimal":
70
+ default:
71
+ return new Intl.NumberFormat(locale, {
72
+ minimumFractionDigits: effectivePrecision,
73
+ maximumFractionDigits: effectivePrecision,
74
+ }).format(num);
75
+ }
76
+ },
77
+ [format, locale, currency, effectivePrecision]
78
+ );
79
+
80
+ const parseValue = useCallback(
81
+ (text: string): number | null => {
82
+ // Remove currency symbols, spaces, thousand separators, and percentage signs
83
+ let cleaned = text.replace(/[^\d.-]/g, "");
84
+
85
+ // Handle negative sign
86
+ if (!allowNegative) {
87
+ cleaned = cleaned.replace(/-/g, "");
88
+ }
89
+
90
+ const parsed = parseFloat(cleaned);
91
+
92
+ if (isNaN(parsed) || cleaned === "" || cleaned === "-") return null;
93
+
94
+ // Apply min/max constraints
95
+ let constrained = parsed;
96
+ if (min !== undefined && constrained < min) constrained = min;
97
+ if (max !== undefined && constrained > max) constrained = max;
98
+
99
+ return constrained;
100
+ },
101
+ [min, max, allowNegative]
102
+ );
103
+
104
+ const handleChangeText = useCallback(
105
+ (text: string) => {
106
+ // When focused, validate input before allowing it
107
+ if (isFocused) {
108
+ // Remove formatting characters to get raw input
109
+ let cleaned = text.replace(/[^\d.-]/g, "");
110
+
111
+ // Validate negative sign
112
+ if (!allowNegative && cleaned.includes("-")) {
113
+ return;
114
+ }
115
+
116
+ // Ensure negative sign is only at the start
117
+ if (allowNegative) {
118
+ const negativeCount = (cleaned.match(/-/g) || []).length;
119
+ if (negativeCount > 1 || (cleaned.includes("-") && cleaned.indexOf("-") !== 0)) {
120
+ return;
121
+ }
122
+ }
123
+
124
+ // Check decimal precision (skip for integer format)
125
+ if (effectivePrecision >= 0) {
126
+ const decimalIndex = cleaned.indexOf(".");
127
+ if (decimalIndex !== -1) {
128
+ const decimalPart = cleaned.substring(decimalIndex + 1);
129
+
130
+ // Prevent typing more decimals than allowed precision
131
+ if (decimalPart.length > effectivePrecision) {
132
+ return;
133
+ }
134
+ }
135
+
136
+ // Prevent multiple decimal points
137
+ const decimalCount = (cleaned.match(/\./g) || []).length;
138
+ if (decimalCount > 1) {
139
+ return;
140
+ }
141
+
142
+ // Prevent decimal point for integer format
143
+ if (format === "integer" && cleaned.includes(".")) {
144
+ return;
145
+ }
146
+ }
147
+ }
148
+
149
+ setDisplayValue(text);
150
+ const value = parseValue(text);
151
+ setNumericValue(value);
152
+ onChange?.(value);
153
+ },
154
+ [parseValue, onChange, isFocused, effectivePrecision, allowNegative, format]
155
+ );
156
+
157
+ const handleBlur = useCallback(() => {
158
+ setIsFocused(false);
159
+ if (numericValue !== null) {
160
+ setDisplayValue(formatValue(numericValue));
161
+ } else {
162
+ setDisplayValue("");
163
+ }
164
+ }, [numericValue, formatValue]);
165
+
166
+ const handleFocus = useCallback(() => {
167
+ setIsFocused(true);
168
+ if (numericValue !== null) {
169
+ setDisplayValue(numericValue.toString());
170
+ }
171
+ }, [numericValue]);
172
+
173
+ const setValue = useCallback(
174
+ (value: number | null) => {
175
+ setNumericValue(value);
176
+ if (value !== null) {
177
+ if (isFocused) {
178
+ setDisplayValue(value.toString());
179
+ } else {
180
+ setDisplayValue(formatValue(value));
181
+ }
182
+ } else {
183
+ setDisplayValue("");
184
+ }
185
+ onChange?.(value);
186
+ },
187
+ [isFocused, formatValue, onChange]
188
+ );
189
+
190
+ // Determine keyboard type based on format
191
+ const keyboardType = format === "integer" ? (allowNegative ? ("numeric" as const) : ("number-pad" as const)) : ("decimal-pad" as const);
192
+
193
+ return {
194
+ value: displayValue,
195
+ numericValue,
196
+ onChangeText: handleChangeText,
197
+ onBlur: handleBlur,
198
+ onFocus: handleFocus,
199
+ keyboardType,
200
+ setValue,
201
+ };
202
+ }
@@ -0,0 +1,188 @@
1
+ import * as React from "react";
2
+ import { useWindowDimensions, type LayoutRectangle, type ScaledSize } from "react-native";
3
+
4
+ interface Insets {
5
+ top?: number;
6
+ bottom?: number;
7
+ left?: number;
8
+ right?: number;
9
+ }
10
+
11
+ type UseRelativePositionArgs = Omit<GetContentStyleArgs, "triggerPosition" | "contentLayout" | "dimensions"> & {
12
+ triggerPosition: LayoutPosition | null;
13
+ contentLayout: LayoutRectangle | null;
14
+ };
15
+
16
+ export function useRelativePosition({
17
+ align,
18
+ avoidCollisions,
19
+ triggerPosition,
20
+ contentLayout,
21
+ alignOffset,
22
+ insets,
23
+ sideOffset,
24
+ side,
25
+ }: UseRelativePositionArgs) {
26
+ const dimensions = useWindowDimensions();
27
+
28
+ return React.useMemo(() => {
29
+ if (!triggerPosition || !contentLayout) {
30
+ return {
31
+ position: "absolute",
32
+ opacity: 0,
33
+ top: dimensions.height,
34
+ zIndex: -9999999,
35
+ } as const;
36
+ }
37
+ return getContentStyle({
38
+ align,
39
+ avoidCollisions,
40
+ contentLayout,
41
+ side,
42
+ triggerPosition,
43
+ alignOffset,
44
+ insets,
45
+ sideOffset,
46
+ dimensions,
47
+ });
48
+ }, [align, avoidCollisions, side, alignOffset, insets, triggerPosition, contentLayout, dimensions.width, dimensions.height]);
49
+ }
50
+
51
+ export interface LayoutPosition {
52
+ pageY: number;
53
+ pageX: number;
54
+ width: number;
55
+ height: number;
56
+ }
57
+
58
+ interface GetPositionArgs {
59
+ dimensions: ScaledSize;
60
+ avoidCollisions: boolean;
61
+ triggerPosition: LayoutPosition;
62
+ contentLayout: LayoutRectangle;
63
+ insets?: Insets;
64
+ }
65
+
66
+ interface GetSidePositionArgs extends GetPositionArgs {
67
+ side: "top" | "bottom";
68
+ sideOffset: number;
69
+ }
70
+
71
+ export const DEFAULT_LAYOUT: LayoutRectangle = { x: 0, y: 0, width: 0, height: 0 };
72
+ export const DEFAULT_POSITION: LayoutPosition = { height: 0, width: 0, pageX: 0, pageY: 0 };
73
+
74
+ function getSidePosition({ side, triggerPosition, contentLayout, sideOffset, insets, avoidCollisions, dimensions }: GetSidePositionArgs) {
75
+ const insetTop = insets?.top ?? 0;
76
+ const insetBottom = insets?.bottom ?? 0;
77
+ const positionTop = triggerPosition?.pageY - sideOffset - contentLayout.height;
78
+ const positionBottom = triggerPosition.pageY + triggerPosition.height + sideOffset;
79
+
80
+ if (!avoidCollisions) {
81
+ return {
82
+ top: side === "top" ? positionTop : positionBottom,
83
+ };
84
+ }
85
+
86
+ if (side === "top") {
87
+ return {
88
+ top: Math.min(Math.max(insetTop, positionTop), dimensions.height - insetBottom - contentLayout.height),
89
+ };
90
+ }
91
+
92
+ return {
93
+ top: Math.min(dimensions.height - insetBottom - contentLayout.height, positionBottom),
94
+ };
95
+ }
96
+
97
+ interface GetAlignPositionArgs extends GetPositionArgs {
98
+ align: "start" | "center" | "end";
99
+ alignOffset: number;
100
+ }
101
+
102
+ function getAlignPosition({ align, avoidCollisions, contentLayout, triggerPosition, alignOffset, insets, dimensions }: GetAlignPositionArgs) {
103
+ const insetLeft = insets?.left ?? 0;
104
+ const insetRight = insets?.right ?? 0;
105
+ const maxContentWidth = dimensions.width - insetLeft - insetRight;
106
+
107
+ const contentWidth = Math.min(contentLayout.width, maxContentWidth);
108
+
109
+ let left = getLeftPosition(align, triggerPosition.pageX, triggerPosition.width, contentWidth, alignOffset, insetLeft, insetRight, dimensions);
110
+
111
+ if (avoidCollisions) {
112
+ const doesCollide = left < insetLeft || left + contentWidth > dimensions.width - insetRight;
113
+ if (doesCollide) {
114
+ const spaceLeft = left - insetLeft;
115
+ const spaceRight = dimensions.width - insetRight - (left + contentWidth);
116
+
117
+ if (spaceLeft > spaceRight && spaceLeft >= contentWidth) {
118
+ left = insetLeft;
119
+ } else if (spaceRight >= contentWidth) {
120
+ left = dimensions.width - insetRight - contentWidth;
121
+ } else {
122
+ const centeredPosition = Math.max(insetLeft, (dimensions.width - contentWidth - insetRight) / 2);
123
+ left = centeredPosition;
124
+ }
125
+ }
126
+ }
127
+
128
+ return { left, maxWidth: maxContentWidth };
129
+ }
130
+
131
+ function getLeftPosition(
132
+ align: "start" | "center" | "end",
133
+ triggerPageX: number,
134
+ triggerWidth: number,
135
+ contentWidth: number,
136
+ alignOffset: number,
137
+ insetLeft: number,
138
+ insetRight: number,
139
+ dimensions: ScaledSize
140
+ ) {
141
+ let left = 0;
142
+ if (align === "start") {
143
+ left = triggerPageX;
144
+ }
145
+ if (align === "center") {
146
+ left = triggerPageX + triggerWidth / 2 - contentWidth / 2;
147
+ }
148
+ if (align === "end") {
149
+ left = triggerPageX + triggerWidth - contentWidth;
150
+ }
151
+ return Math.max(insetLeft, Math.min(left + alignOffset, dimensions.width - contentWidth - insetRight));
152
+ }
153
+
154
+ type GetContentStyleArgs = GetPositionArgs & GetSidePositionArgs & GetAlignPositionArgs;
155
+
156
+ function getContentStyle({
157
+ align,
158
+ avoidCollisions,
159
+ contentLayout,
160
+ side,
161
+ triggerPosition,
162
+ alignOffset,
163
+ insets,
164
+ sideOffset,
165
+ dimensions,
166
+ }: GetContentStyleArgs) {
167
+ return Object.assign(
168
+ { position: "absolute" } as const,
169
+ getSidePosition({
170
+ side,
171
+ triggerPosition,
172
+ contentLayout,
173
+ sideOffset,
174
+ insets,
175
+ avoidCollisions,
176
+ dimensions,
177
+ }),
178
+ getAlignPosition({
179
+ align,
180
+ avoidCollisions,
181
+ triggerPosition,
182
+ contentLayout,
183
+ alignOffset,
184
+ insets,
185
+ dimensions,
186
+ })
187
+ );
188
+ }
@@ -1,13 +1,11 @@
1
1
  import React, { useState } from "react";
2
- import { Pressable, StyleProp, ViewStyle } from "react-native";
2
+ import { Pressable, PressableProps, StyleProp, ViewStyle } from "react-native";
3
3
  import { ButtonStyles, ButtonState } from "./types";
4
4
  import { ButtonPrimitiveContext } from "./button-context";
5
5
 
6
- export interface ButtonPrimitiveRootProps {
6
+ export interface ButtonPrimitiveRootProps extends PressableProps {
7
7
  children?: React.ReactNode;
8
8
 
9
- onPress?: () => void;
10
-
11
9
  isDisabled?: boolean;
12
10
  isLoading?: boolean;
13
11
 
@@ -0,0 +1,25 @@
1
+ import { createContext, Dispatch, useContext } from "react";
2
+ import { LayoutRectangle } from "react-native";
3
+ import { DropdownMenuStyles } from "./types";
4
+ import { LayoutPosition } from "@/hooks/use-relative-position";
5
+
6
+ export interface DropdownMenuContext {
7
+ isOpen: boolean;
8
+ setIsOpen: Dispatch<React.SetStateAction<boolean>>;
9
+ triggerPosition: LayoutPosition;
10
+ setTriggerPosition: Dispatch<React.SetStateAction<LayoutPosition>>;
11
+ contentLayout: LayoutRectangle;
12
+ setContentLayout: Dispatch<React.SetStateAction<LayoutRectangle>>;
13
+
14
+ styles?: DropdownMenuStyles;
15
+ }
16
+
17
+ export const DropdownMenuContext = createContext<DropdownMenuContext | undefined>(undefined);
18
+
19
+ export const useDropdownMenu = () => {
20
+ const context = useContext(DropdownMenuContext);
21
+ if (!context) {
22
+ throw new Error("useDropdownMenu must be used within a DropdownMenuRoot");
23
+ }
24
+ return context;
25
+ };
@@ -0,0 +1,47 @@
1
+ import React, { useState } from "react";
2
+ import { StyleProp, Text, TextStyle } from "react-native";
3
+ import { useDropdownMenu } from "./context";
4
+ import { DropdownMenuButtonState } from "./types";
5
+
6
+ export interface DropdownMenuButtonProps {
7
+ children?: string;
8
+ onPress?: () => void;
9
+ onMouseEnter?: () => void;
10
+ onMouseLeave?: () => void;
11
+
12
+ render?: (props: DropdownMenuButtonProps) => React.ReactElement;
13
+
14
+ style?: StyleProp<TextStyle>;
15
+ }
16
+
17
+ const calculateState = (isHovered: boolean): DropdownMenuButtonState => {
18
+ if (isHovered) {
19
+ return "hovered";
20
+ }
21
+ return "default";
22
+ };
23
+
24
+ export function DropdownMenuButton(props: DropdownMenuButtonProps) {
25
+ const menu = useDropdownMenu();
26
+ const [isHovered, setIsHovered] = useState(false);
27
+ const state = calculateState(isHovered);
28
+ const composedStyle = [menu.styles?.button?.default, menu.styles?.button?.[state], props.style];
29
+
30
+ const handlePress = () => {
31
+ props.onPress?.();
32
+ menu.setIsOpen((prev) => !prev);
33
+ };
34
+
35
+ const Component = props.render ?? Text;
36
+ return (
37
+ <Component
38
+ {...props}
39
+ onPress={handlePress}
40
+ onMouseEnter={() => setIsHovered(true)}
41
+ onMouseLeave={() => setIsHovered(false)}
42
+ style={composedStyle}
43
+ >
44
+ {props.children}
45
+ </Component>
46
+ );
47
+ }
@@ -0,0 +1,39 @@
1
+ import React from "react";
2
+ import { StyleProp, View, ViewStyle } from "react-native";
3
+ import { useDropdownMenu } from "./context";
4
+ import { useRelativePosition } from "@/hooks/use-relative-position";
5
+
6
+ export interface DropdownMenuContentProps {
7
+ children?: React.ReactNode;
8
+
9
+ render?: (props: DropdownMenuContentProps) => React.ReactNode;
10
+
11
+ style?: StyleProp<ViewStyle>;
12
+ }
13
+
14
+ export function DropdownMenuContent(props: DropdownMenuContentProps) {
15
+ const menu = useDropdownMenu();
16
+
17
+ const positionStyle = useRelativePosition({
18
+ align: "start",
19
+ avoidCollisions: true,
20
+ triggerPosition: menu.triggerPosition,
21
+ contentLayout: menu.contentLayout,
22
+ alignOffset: 0,
23
+ side: "bottom",
24
+ sideOffset: 0,
25
+ });
26
+
27
+ const composedStyle = [positionStyle, menu.styles?.content, props.style];
28
+
29
+ const Component = props.render ?? View;
30
+ return (
31
+ <Component
32
+ {...props}
33
+ onLayout={(e) => {
34
+ menu.setContentLayout(e.nativeEvent.layout);
35
+ }}
36
+ style={composedStyle}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import { StyleProp, View, ViewStyle } from "react-native";
3
+ import { useDropdownMenu } from "./context";
4
+
5
+ export interface DropdownMenuDividerProps {
6
+ render?: (props: DropdownMenuDividerProps) => React.ReactNode;
7
+
8
+ style?: StyleProp<ViewStyle>;
9
+ }
10
+
11
+ export function DropdownMenuDivider(props: DropdownMenuDividerProps) {
12
+ const menu = useDropdownMenu();
13
+
14
+ const composedStyle = [menu.styles?.divider, props.style];
15
+
16
+ const Component = props.render ?? View;
17
+ return <Component {...props} style={composedStyle} />;
18
+ }
@@ -0,0 +1,29 @@
1
+ import React from "react";
2
+ import { useDropdownMenu } from "./context";
3
+ import { Pressable, StyleProp, StyleSheet, ViewStyle } from "react-native";
4
+
5
+ export interface DropdownMenuOverlayProps {
6
+ children?: React.ReactNode;
7
+
8
+ render?: (props: DropdownMenuOverlayProps) => React.ReactElement;
9
+
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ export function DropdownMenuOverlay(props: DropdownMenuOverlayProps) {
14
+ const menu = useDropdownMenu();
15
+
16
+ const composedStyle = [StyleSheet.absoluteFill, menu.styles?.overlay, props.style];
17
+
18
+ const Component = props.render ?? Pressable;
19
+ return (
20
+ <Component
21
+ onPress={() => {
22
+ menu.setIsOpen(false);
23
+ }}
24
+ style={composedStyle}
25
+ >
26
+ {props.children}
27
+ </Component>
28
+ );
29
+ }
@@ -0,0 +1,21 @@
1
+ import React from "react";
2
+ import { Portal } from "../portal";
3
+ import { useDropdownMenu, DropdownMenuContext } from "./context";
4
+
5
+ export interface DropdownMenuPortalProps {
6
+ children?: React.ReactNode;
7
+ }
8
+
9
+ export function DropdownMenuPortal(props: DropdownMenuPortalProps) {
10
+ const menu = useDropdownMenu();
11
+
12
+ if (!menu.isOpen) {
13
+ return null;
14
+ }
15
+
16
+ return (
17
+ <Portal name="dropdown-menu-portal">
18
+ <DropdownMenuContext.Provider value={menu}>{props.children}</DropdownMenuContext.Provider>
19
+ </Portal>
20
+ );
21
+ }
@@ -0,0 +1,35 @@
1
+ import React, { useState } from "react";
2
+ import { LayoutRectangle } from "react-native";
3
+ import { DropdownMenuStyles } from "./types";
4
+ import { DropdownMenuContext } from "./context";
5
+ import { DEFAULT_LAYOUT, DEFAULT_POSITION, LayoutPosition } from "@/hooks/use-relative-position";
6
+
7
+ export interface DropdownMenuRootProps {
8
+ children?: React.ReactNode;
9
+
10
+ render?: (props: DropdownMenuRootProps) => React.ReactNode;
11
+
12
+ styles?: DropdownMenuStyles;
13
+ }
14
+
15
+ export function DropdownMenuRoot(props: DropdownMenuRootProps) {
16
+ const [isOpen, setIsOpen] = useState(false);
17
+ const [triggerPosition, setTriggerPosition] = useState<LayoutPosition>(DEFAULT_POSITION);
18
+ const [contentLayout, setContentLayout] = useState<LayoutRectangle>(DEFAULT_LAYOUT);
19
+
20
+ return (
21
+ <DropdownMenuContext.Provider
22
+ value={{
23
+ isOpen,
24
+ setIsOpen,
25
+ triggerPosition,
26
+ setTriggerPosition,
27
+ contentLayout,
28
+ setContentLayout,
29
+ styles: props.styles,
30
+ }}
31
+ >
32
+ {props.children}
33
+ </DropdownMenuContext.Provider>
34
+ );
35
+ }
@@ -0,0 +1,47 @@
1
+ import React, { forwardRef, RefAttributes, useImperativeHandle, useRef } from "react";
2
+ import { PressableProps, View } from "react-native";
3
+ import { useDropdownMenu } from "./context";
4
+
5
+ export interface DropdownMenuTriggerProps extends PressableProps {
6
+ children: React.ReactElement<RefAttributes<View> & PressableProps>;
7
+ }
8
+
9
+ export interface DropdownMenuTriggerRef {
10
+ open: () => void;
11
+ close: () => void;
12
+ }
13
+
14
+ export const DropdownMenuTrigger = forwardRef<DropdownMenuTriggerRef, DropdownMenuTriggerProps>((props, ref) => {
15
+ const dropdownMenu = useDropdownMenu();
16
+ const triggerRef = useRef<View>(null);
17
+
18
+ const onTriggerPress = async () => {
19
+ triggerRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
20
+ dropdownMenu.setTriggerPosition({
21
+ height,
22
+ width,
23
+ pageX,
24
+ pageY,
25
+ });
26
+ });
27
+
28
+ dropdownMenu.setIsOpen((prev) => !prev);
29
+ };
30
+
31
+ useImperativeHandle(ref, () => ({
32
+ open: () => dropdownMenu.setIsOpen(true),
33
+ close: () => dropdownMenu.setIsOpen(false),
34
+ }));
35
+
36
+ return React.cloneElement(props.children, {
37
+ ref: triggerRef,
38
+ onPress: onTriggerPress,
39
+ role: "button",
40
+ accessible: true,
41
+ accessibilityRole: "button",
42
+ accessibilityState: { expanded: dropdownMenu.isOpen },
43
+ ...props.children.props,
44
+ });
45
+ });
46
+
47
+ DropdownMenuTrigger.displayName = "DropdownMenuTrigger";