@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.
- package/dist/components/index.d.mts +73 -4
- package/dist/components/index.mjs +156 -8
- package/dist/hooks/index.d.mts +32 -2
- package/dist/hooks/index.mjs +79 -2
- package/dist/{index-BLsiF42Z.d.mts → index-pCM7YTs1.d.mts} +165 -9
- package/dist/index.d.mts +3 -3
- package/dist/index.mjs +3 -2
- package/dist/primitives/index.d.mts +3 -2
- package/dist/primitives/index.mjs +3 -2
- package/dist/{primitives-CyDqzNcp.mjs → primitives-DNeYBN-3.mjs} +330 -12
- package/dist/{toast-manager-BOORCQn8.mjs → toast-manager-BfoJ-_dB.mjs} +1 -1
- package/dist/use-numeric-mask-B9WZG25o.d.mts +33 -0
- package/dist/use-numeric-mask-BQlz1Pus.mjs +113 -0
- package/dist/use-relative-position-BTKEyT1F.mjs +106 -0
- package/dist/use-relative-position-DBzhrBU7.d.mts +61 -0
- package/package.json +1 -1
- package/src/components/button/button.tsx +7 -4
- package/src/components/dropdown-menu/dropdown-menu.tsx +49 -0
- package/src/components/dropdown-menu/variants/default.tsx +40 -0
- package/src/components/dropdown-menu/variants/index.ts +5 -0
- package/src/components/index.ts +3 -1
- package/src/components/input/index.ts +2 -0
- package/src/components/input/numeric-input.tsx +73 -0
- package/src/components/popover/popover.tsx +51 -0
- package/src/components/popover/variants/default.tsx +26 -0
- package/src/components/popover/variants/index.ts +5 -0
- package/src/hooks/index.ts +4 -1
- package/src/hooks/use-currency-mask.ts +141 -0
- package/src/hooks/use-numeric-mask.ts +202 -0
- package/src/hooks/use-relative-position.ts +188 -0
- package/src/primitives/button/button-root.tsx +2 -4
- package/src/primitives/dropdown-menu/context.ts +25 -0
- package/src/primitives/dropdown-menu/dropdown-menu-button.tsx +47 -0
- package/src/primitives/dropdown-menu/dropdown-menu-content.tsx +39 -0
- package/src/primitives/dropdown-menu/dropdown-menu-divider.tsx +18 -0
- package/src/primitives/dropdown-menu/dropdown-menu-overlay.tsx +29 -0
- package/src/primitives/dropdown-menu/dropdown-menu-portal.tsx +21 -0
- package/src/primitives/dropdown-menu/dropdown-menu-root.tsx +35 -0
- package/src/primitives/dropdown-menu/dropdown-menu-trigger.tsx +47 -0
- package/src/primitives/dropdown-menu/index.ts +26 -0
- package/src/primitives/dropdown-menu/types.ts +13 -0
- package/src/primitives/index.ts +2 -0
- package/src/primitives/popover/context.ts +25 -0
- package/src/primitives/popover/index.ts +24 -0
- package/src/primitives/popover/popover-close.tsx +29 -0
- package/src/primitives/popover/popover-content.tsx +39 -0
- package/src/primitives/popover/popover-overlay.tsx +37 -0
- package/src/primitives/popover/popover-portal.tsx +21 -0
- package/src/primitives/popover/popover-root.tsx +35 -0
- package/src/primitives/popover/popover-trigger.tsx +47 -0
- package/src/primitives/popover/types.ts +7 -0
- package/src/utils/get-ref-layout.ts +16 -0
- /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";
|