@korsolutions/ui 0.0.1
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/package.json +22 -0
- package/src/index.ts +6 -0
- package/src/primitives/button/button-context.tsx +19 -0
- package/src/primitives/button/button-label.tsx +19 -0
- package/src/primitives/button/button-root.tsx +37 -0
- package/src/primitives/button/index.ts +11 -0
- package/src/primitives/button/types.ts +9 -0
- package/src/primitives/field/context.ts +30 -0
- package/src/primitives/field/field-control.tsx +28 -0
- package/src/primitives/field/field-label.tsx +16 -0
- package/src/primitives/field/field-root.tsx +69 -0
- package/src/primitives/field/index.ts +14 -0
- package/src/primitives/field/types.ts +10 -0
- package/src/primitives/input/index.ts +1 -0
- package/src/primitives/input/input.tsx +16 -0
- package/src/primitives/portal/index.ts +1 -0
- package/src/primitives/portal/portal.tsx +95 -0
- package/src/primitives/provider.tsx +10 -0
- package/src/primitives/select/context.ts +31 -0
- package/src/primitives/select/index.ts +26 -0
- package/src/primitives/select/select-content.tsx +34 -0
- package/src/primitives/select/select-option.tsx +41 -0
- package/src/primitives/select/select-overlay.tsx +32 -0
- package/src/primitives/select/select-portal.tsx +27 -0
- package/src/primitives/select/select-root.tsx +58 -0
- package/src/primitives/select/select-trigger.tsx +37 -0
- package/src/primitives/select/select-value.tsx +18 -0
- package/src/primitives/select/types.ts +22 -0
- package/src/utils/calculate-styles.ts +19 -0
- package/src/utils/normalize-layout.ts +11 -0
- package/tsconfig.json +4 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@korsolutions/ui",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"main": "index.ts",
|
|
6
|
+
"types": "index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"ts-check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"react": "*",
|
|
15
|
+
"react-native": "*",
|
|
16
|
+
"react-native-reanimated": "4.x",
|
|
17
|
+
"react-native-web": "*"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/react": "^19.2.3"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { ButtonState, ButtonStyles } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface ButtonContext {
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
|
|
7
|
+
state: ButtonState;
|
|
8
|
+
styles?: ButtonStyles;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const ButtonContext = createContext<ButtonContext | undefined>(undefined);
|
|
12
|
+
|
|
13
|
+
export const useButton = () => {
|
|
14
|
+
const context = useContext(ButtonContext);
|
|
15
|
+
if (!context) {
|
|
16
|
+
throw new Error("useButton must be used within a ButtonProvider");
|
|
17
|
+
}
|
|
18
|
+
return context;
|
|
19
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleProp, Text, TextStyle } from "react-native";
|
|
3
|
+
import { useButton } from "./button-context";
|
|
4
|
+
|
|
5
|
+
export interface ButtonLabelProps {
|
|
6
|
+
children?: string;
|
|
7
|
+
|
|
8
|
+
render?: (props: this) => React.ReactElement;
|
|
9
|
+
|
|
10
|
+
style?: StyleProp<TextStyle>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ButtonLabel(props: ButtonLabelProps) {
|
|
14
|
+
const button = useButton();
|
|
15
|
+
const calculatedStyle = [button.styles?.label?.default, button.styles?.label?.[button.state], props.style];
|
|
16
|
+
|
|
17
|
+
const Component = props.render ?? Text;
|
|
18
|
+
return <Component style={calculatedStyle}>{props.children}</Component>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { ButtonStyles, ButtonState } from "./types";
|
|
4
|
+
import { ButtonContext } from "./button-context";
|
|
5
|
+
|
|
6
|
+
export interface ButtonRootProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
|
|
9
|
+
onPress?: () => void;
|
|
10
|
+
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
|
|
13
|
+
style?: StyleProp<ViewStyle>;
|
|
14
|
+
styles?: ButtonStyles;
|
|
15
|
+
|
|
16
|
+
render?: (props: this) => React.ReactElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const calculateState = (props: ButtonRootProps): ButtonState => {
|
|
20
|
+
if (props.disabled) {
|
|
21
|
+
return "disabled";
|
|
22
|
+
}
|
|
23
|
+
return "default";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function ButtonRoot(props: ButtonRootProps) {
|
|
27
|
+
const state = calculateState(props);
|
|
28
|
+
|
|
29
|
+
const calculatedStyle = [props.styles?.root?.default, props.styles?.root?.[state], props.style];
|
|
30
|
+
|
|
31
|
+
const Container = props.render ?? Pressable;
|
|
32
|
+
return (
|
|
33
|
+
<ButtonContext.Provider value={{ disabled: props.disabled, state, styles: props.styles }}>
|
|
34
|
+
<Container {...props} style={calculatedStyle} />
|
|
35
|
+
</ButtonContext.Provider>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ButtonRoot } from "./button-root";
|
|
2
|
+
import { ButtonLabel } from "./button-label";
|
|
3
|
+
|
|
4
|
+
export const Button = {
|
|
5
|
+
Root: ButtonRoot,
|
|
6
|
+
Label: ButtonLabel,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type { ButtonRootProps } from "./button-root";
|
|
10
|
+
export type { ButtonLabelProps } from "./button-label";
|
|
11
|
+
export * from "./types";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ButtonRootProps } from "./button-root";
|
|
2
|
+
import { ButtonLabelProps } from "./button-label";
|
|
3
|
+
|
|
4
|
+
export type ButtonState = "default" | "disabled";
|
|
5
|
+
|
|
6
|
+
export interface ButtonStyles {
|
|
7
|
+
root?: Partial<Record<ButtonState, ButtonRootProps["style"]>>;
|
|
8
|
+
label?: Partial<Record<ButtonState, ButtonLabelProps["style"]>>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { FieldState, FieldStyles } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface FieldContext {
|
|
5
|
+
value: string | null;
|
|
6
|
+
onChange?: (value: string) => void;
|
|
7
|
+
|
|
8
|
+
focused: boolean;
|
|
9
|
+
setFocused: React.Dispatch<React.SetStateAction<boolean>>;
|
|
10
|
+
|
|
11
|
+
hovered: boolean;
|
|
12
|
+
setHovered: React.Dispatch<React.SetStateAction<boolean>>;
|
|
13
|
+
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
required?: boolean;
|
|
16
|
+
error?: string | null;
|
|
17
|
+
|
|
18
|
+
state: FieldState;
|
|
19
|
+
styles?: FieldStyles<unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const FieldContext = createContext<FieldContext | undefined>(undefined);
|
|
23
|
+
|
|
24
|
+
export const useField = () => {
|
|
25
|
+
const context = useContext(FieldContext);
|
|
26
|
+
if (!context) {
|
|
27
|
+
throw new Error("useField must be used within a FieldProvider");
|
|
28
|
+
}
|
|
29
|
+
return context;
|
|
30
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useField } from "./context";
|
|
3
|
+
import { StyleProp, TextStyle } from "react-native";
|
|
4
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
5
|
+
|
|
6
|
+
interface FieldControlInjectedProps<Style> {
|
|
7
|
+
value?: string;
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
|
|
10
|
+
onFocus?: () => void;
|
|
11
|
+
onBlur?: () => void;
|
|
12
|
+
|
|
13
|
+
style?: StyleProp<Style>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FieldControlProps<Style = TextStyle> {
|
|
17
|
+
style?: StyleProp<Style>;
|
|
18
|
+
render: (props: FieldControlInjectedProps<Style>) => React.ReactElement;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function FieldControl<Style = TextStyle>(props: FieldControlProps<Style>) {
|
|
22
|
+
const { value, onChange, setFocused, ...field } = useField();
|
|
23
|
+
|
|
24
|
+
const calculatedStyle = calculateComposedStyles(field.styles, field.state, "control", props.style) as StyleProp<Style>;
|
|
25
|
+
|
|
26
|
+
const Component = props.render;
|
|
27
|
+
return <Component value={value} onChange={onChange} onBlur={() => setFocused(false)} onFocus={() => setFocused(true)} style={calculatedStyle} />;
|
|
28
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useField } from "./context";
|
|
3
|
+
import Animated from "react-native-reanimated";
|
|
4
|
+
|
|
5
|
+
export interface FieldLabelProps {
|
|
6
|
+
children: string;
|
|
7
|
+
style?: React.ComponentProps<typeof Animated.Text>["style"];
|
|
8
|
+
render?: (props: FieldLabelProps) => React.ReactNode;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function FieldLabel(props: FieldLabelProps) {
|
|
12
|
+
const field = useField();
|
|
13
|
+
const calculatedStyle = [field.styles?.label?.default, field.styles?.label?.[field.state], props.style];
|
|
14
|
+
|
|
15
|
+
return props.render ? props.render(props) : <Animated.Text style={calculatedStyle}>{props.children}</Animated.Text>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { Pressable, StyleProp, View, ViewStyle } from "react-native";
|
|
3
|
+
import { FieldContext } from "./context";
|
|
4
|
+
import { FieldState, FieldStyles } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface FieldRootProps {
|
|
7
|
+
value?: string | null;
|
|
8
|
+
onChange?: (value: string) => void;
|
|
9
|
+
|
|
10
|
+
required?: boolean;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
error?: string | null;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
|
|
16
|
+
styles?: FieldStyles<unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const calculateState = (props: FieldRootProps, focused: boolean, hovered: boolean): FieldState => {
|
|
20
|
+
if (props.disabled) {
|
|
21
|
+
return "disabled";
|
|
22
|
+
}
|
|
23
|
+
if (props.error) {
|
|
24
|
+
return "error";
|
|
25
|
+
}
|
|
26
|
+
if (focused) {
|
|
27
|
+
return "focused";
|
|
28
|
+
}
|
|
29
|
+
if (hovered) {
|
|
30
|
+
return "hovered";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return "default";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function FieldRoot(props: FieldRootProps) {
|
|
37
|
+
const [focused, setFocused] = useState(false);
|
|
38
|
+
const [hovered, setHovered] = useState(false);
|
|
39
|
+
|
|
40
|
+
const state = calculateState(props, focused, hovered);
|
|
41
|
+
|
|
42
|
+
const calculatedStyle = [props.styles?.root?.default, props.styles?.root?.[state], props.style];
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<FieldContext.Provider
|
|
46
|
+
value={{
|
|
47
|
+
value: props.value ?? null,
|
|
48
|
+
onChange: props.onChange,
|
|
49
|
+
|
|
50
|
+
focused,
|
|
51
|
+
setFocused,
|
|
52
|
+
|
|
53
|
+
hovered,
|
|
54
|
+
setHovered,
|
|
55
|
+
|
|
56
|
+
required: props.required,
|
|
57
|
+
disabled: props.disabled,
|
|
58
|
+
error: props.error ?? null,
|
|
59
|
+
|
|
60
|
+
state: state,
|
|
61
|
+
styles: props.styles,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<Pressable onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)} style={calculatedStyle}>
|
|
65
|
+
{props.children}
|
|
66
|
+
</Pressable>
|
|
67
|
+
</FieldContext.Provider>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { FieldRoot } from "./field-root";
|
|
2
|
+
import { FieldLabel } from "./field-label";
|
|
3
|
+
import { FieldControl } from "./field-control";
|
|
4
|
+
|
|
5
|
+
export const Field = {
|
|
6
|
+
Root: FieldRoot,
|
|
7
|
+
Label: FieldLabel,
|
|
8
|
+
Control: FieldControl,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type { FieldRootProps } from "./field-root";
|
|
12
|
+
export type { FieldLabelProps } from "./field-label";
|
|
13
|
+
export type { FieldControlProps } from "./field-control";
|
|
14
|
+
export * from "./types";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { FieldLabelProps } from "./field-label";
|
|
2
|
+
import { FieldRootProps } from "./field-root";
|
|
3
|
+
|
|
4
|
+
export type FieldState = "default" | "disabled" | "error" | "focused" | "hovered";
|
|
5
|
+
|
|
6
|
+
export interface FieldStyles<TControlStyle> {
|
|
7
|
+
root?: Partial<Record<FieldState, FieldRootProps["style"]>>;
|
|
8
|
+
label?: Partial<Record<FieldState, FieldLabelProps["style"]>>;
|
|
9
|
+
control?: Partial<Record<FieldState, TControlStyle>>;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./input";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { TextInput, TextInputProps } from "react-native";
|
|
2
|
+
|
|
3
|
+
export interface InputProps {
|
|
4
|
+
defaultValue?: TextInputProps["defaultValue"];
|
|
5
|
+
value?: TextInputProps["value"];
|
|
6
|
+
onChange?: TextInputProps["onChangeText"];
|
|
7
|
+
|
|
8
|
+
onFocus?: TextInputProps["onFocus"];
|
|
9
|
+
onBlur?: TextInputProps["onBlur"];
|
|
10
|
+
|
|
11
|
+
style?: TextInputProps["style"];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Input(props: InputProps) {
|
|
15
|
+
return <TextInput {...props} onChange={undefined} onChangeText={props.onChange} />;
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./portal";
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { Platform, type View, type ViewStyle } from "react-native";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PORTAL_HOST = "KOR_NATIVE_DEFAULT_HOST_NAME";
|
|
6
|
+
|
|
7
|
+
type PortalMap = Map<string, React.ReactNode>;
|
|
8
|
+
type PortalHostMap = Map<string, PortalMap>;
|
|
9
|
+
|
|
10
|
+
type PortalStore = {
|
|
11
|
+
map: PortalHostMap;
|
|
12
|
+
listeners: Set<() => void>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const store: PortalStore = {
|
|
16
|
+
map: new Map<string, PortalMap>().set(DEFAULT_PORTAL_HOST, new Map<string, React.ReactNode>()),
|
|
17
|
+
listeners: new Set(),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function emit() {
|
|
21
|
+
for (const cb of store.listeners) cb();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getSnapshot() {
|
|
25
|
+
return store.map;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function subscribe(cb: () => void) {
|
|
29
|
+
store.listeners.add(cb);
|
|
30
|
+
return () => {
|
|
31
|
+
store.listeners.delete(cb);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function updatePortal(hostName: string, name: string, children: React.ReactNode) {
|
|
36
|
+
const next = new Map(store.map);
|
|
37
|
+
const portal = next.get(hostName) ?? new Map<string, React.ReactNode>();
|
|
38
|
+
portal.set(name, children);
|
|
39
|
+
next.set(hostName, portal);
|
|
40
|
+
store.map = next;
|
|
41
|
+
emit();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function removePortal(hostName: string, name: string) {
|
|
45
|
+
const next = new Map(store.map);
|
|
46
|
+
const portal = next.get(hostName) ?? new Map<string, React.ReactNode>();
|
|
47
|
+
portal.delete(name);
|
|
48
|
+
next.set(hostName, portal);
|
|
49
|
+
store.map = next;
|
|
50
|
+
emit();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function PortalHost({ name = DEFAULT_PORTAL_HOST }: { name?: string }) {
|
|
54
|
+
const map = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
55
|
+
const portalMap = map.get(name) ?? new Map<string, React.ReactNode>();
|
|
56
|
+
if (portalMap.size === 0) return null;
|
|
57
|
+
return <>{Array.from(portalMap.values())}</>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function Portal({ name, hostName = DEFAULT_PORTAL_HOST, children }: { name: string; hostName?: string; children: React.ReactNode }) {
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
updatePortal(hostName, name, children);
|
|
63
|
+
}, [hostName, name, children]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
return () => {
|
|
67
|
+
removePortal(hostName, name);
|
|
68
|
+
};
|
|
69
|
+
}, [hostName, name]);
|
|
70
|
+
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ROOT: ViewStyle = {
|
|
75
|
+
flex: 1,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export function useModalPortalRoot() {
|
|
79
|
+
const ref = useRef<View>(null);
|
|
80
|
+
const [offset, setSideOffSet] = useState(0);
|
|
81
|
+
|
|
82
|
+
const onLayout = useCallback(() => {
|
|
83
|
+
if (Platform.OS === "web") return;
|
|
84
|
+
ref.current?.measure((_x, _y, _width, _height, _pageX, pageY) => {
|
|
85
|
+
setSideOffSet(-pageY);
|
|
86
|
+
});
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
ref,
|
|
91
|
+
offset,
|
|
92
|
+
onLayout,
|
|
93
|
+
style: ROOT,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createContext, Dispatch, useContext } from "react";
|
|
2
|
+
import { SelectOption, SelectState, SelectStyles } from "./types";
|
|
3
|
+
import { LayoutRectangle } from "react-native";
|
|
4
|
+
|
|
5
|
+
export interface SelectContext {
|
|
6
|
+
value: string | null;
|
|
7
|
+
onChange?: (value: string) => void;
|
|
8
|
+
placeholder: string | null;
|
|
9
|
+
|
|
10
|
+
isOpen: boolean;
|
|
11
|
+
setIsOpen: Dispatch<React.SetStateAction<boolean>>;
|
|
12
|
+
triggerLayout: LayoutRectangle | null;
|
|
13
|
+
setTriggerLayout: Dispatch<React.SetStateAction<LayoutRectangle | null>>;
|
|
14
|
+
options: Array<SelectOption>;
|
|
15
|
+
setOptions: Dispatch<React.SetStateAction<Array<SelectOption>>>;
|
|
16
|
+
|
|
17
|
+
disabled: boolean;
|
|
18
|
+
|
|
19
|
+
state: SelectState;
|
|
20
|
+
styles: SelectStyles | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const SelectContext = createContext<SelectContext | undefined>(undefined);
|
|
24
|
+
|
|
25
|
+
export const useSelect = () => {
|
|
26
|
+
const context = useContext(SelectContext);
|
|
27
|
+
if (!context) {
|
|
28
|
+
throw new Error("useSelect must be used within a SelectProvider");
|
|
29
|
+
}
|
|
30
|
+
return context;
|
|
31
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SelectRoot } from "./select-root";
|
|
2
|
+
import { SelectTrigger } from "./select-trigger";
|
|
3
|
+
import { SelectValue } from "./select-value";
|
|
4
|
+
import { SelectPortal } from "./select-portal";
|
|
5
|
+
import { SelectOverlay } from "./select-overlay";
|
|
6
|
+
import { SelectContent } from "./select-content";
|
|
7
|
+
import { SelectOption } from "./select-option";
|
|
8
|
+
|
|
9
|
+
export const Select = {
|
|
10
|
+
Root: SelectRoot,
|
|
11
|
+
Trigger: SelectTrigger,
|
|
12
|
+
Value: SelectValue,
|
|
13
|
+
Portal: SelectPortal,
|
|
14
|
+
Overlay: SelectOverlay,
|
|
15
|
+
Content: SelectContent,
|
|
16
|
+
Option: SelectOption,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type { SelectRootProps } from "./select-root";
|
|
20
|
+
export type { SelectTriggerProps } from "./select-trigger";
|
|
21
|
+
export type { SelectValueProps } from "./select-value";
|
|
22
|
+
export type { SelectPortalProps } from "./select-portal";
|
|
23
|
+
export type { SelectOverlayProps } from "./select-overlay";
|
|
24
|
+
export type { SelectContentProps } from "./select-content";
|
|
25
|
+
export type { SelectOptionProps } from "./select-option";
|
|
26
|
+
export type { SelectStyles } from "./types";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { StyleProp, View, ViewStyle } from "react-native";
|
|
3
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
4
|
+
import { useSelect } from "./context";
|
|
5
|
+
|
|
6
|
+
export interface SelectContentProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
|
|
9
|
+
render?: (props: SelectContentProps) => React.ReactElement;
|
|
10
|
+
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SelectContent(props: SelectContentProps) {
|
|
15
|
+
const select = useSelect();
|
|
16
|
+
const composedStyles = calculateComposedStyles(select.styles, select.state, "content", props.style);
|
|
17
|
+
|
|
18
|
+
const Component = props.render ?? View;
|
|
19
|
+
return (
|
|
20
|
+
<Component
|
|
21
|
+
style={[
|
|
22
|
+
composedStyles,
|
|
23
|
+
{
|
|
24
|
+
position: "absolute",
|
|
25
|
+
top: select.triggerLayout?.y! + select.triggerLayout?.height!,
|
|
26
|
+
left: select.triggerLayout?.x!,
|
|
27
|
+
width: select.triggerLayout?.width!,
|
|
28
|
+
},
|
|
29
|
+
]}
|
|
30
|
+
>
|
|
31
|
+
{props.children}
|
|
32
|
+
</Component>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { StyleProp, Text, TextStyle } from "react-native";
|
|
2
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
3
|
+
import { useSelect } from "./context";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
export interface SelectOptionProps {
|
|
7
|
+
children: string;
|
|
8
|
+
value: string;
|
|
9
|
+
|
|
10
|
+
style?: StyleProp<TextStyle>;
|
|
11
|
+
|
|
12
|
+
render?: (props: SelectOptionProps) => React.ReactElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SelectOption(props: SelectOptionProps) {
|
|
16
|
+
const select = useSelect();
|
|
17
|
+
const composedStyles = calculateComposedStyles(select.styles, select.state, "option", props.style);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
select.setOptions((prev) => {
|
|
21
|
+
if (prev.find((option) => option.value === props.value)) {
|
|
22
|
+
return prev;
|
|
23
|
+
}
|
|
24
|
+
return [...prev, { value: props.value, label: props.children }];
|
|
25
|
+
});
|
|
26
|
+
}, [props.value, props.children]);
|
|
27
|
+
|
|
28
|
+
const Component = props.render ?? Text;
|
|
29
|
+
return (
|
|
30
|
+
<Component
|
|
31
|
+
value={props.value}
|
|
32
|
+
onPress={() => {
|
|
33
|
+
select.onChange?.(props.value);
|
|
34
|
+
select.setIsOpen(false);
|
|
35
|
+
}}
|
|
36
|
+
style={composedStyles}
|
|
37
|
+
>
|
|
38
|
+
{props.children}
|
|
39
|
+
</Component>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { useSelect } from "./context";
|
|
3
|
+
import { Pressable, StyleProp, StyleSheet, ViewStyle } from "react-native";
|
|
4
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
5
|
+
|
|
6
|
+
export interface SelectOverlayProps {
|
|
7
|
+
children?: React.ReactNode;
|
|
8
|
+
|
|
9
|
+
onPress?: () => void;
|
|
10
|
+
|
|
11
|
+
style?: StyleProp<ViewStyle>;
|
|
12
|
+
|
|
13
|
+
render?: (props: SelectOverlayProps) => React.ReactElement;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SelectOverlay(props: SelectOverlayProps) {
|
|
17
|
+
const select = useSelect();
|
|
18
|
+
|
|
19
|
+
const composedStyles = calculateComposedStyles(select.styles, select.state, "overlay", props.style);
|
|
20
|
+
|
|
21
|
+
const Component = props.render ?? Pressable;
|
|
22
|
+
return (
|
|
23
|
+
<Component
|
|
24
|
+
onPress={() => {
|
|
25
|
+
select.setIsOpen(false);
|
|
26
|
+
}}
|
|
27
|
+
style={[StyleSheet.absoluteFill, composedStyles]}
|
|
28
|
+
>
|
|
29
|
+
{props.children}
|
|
30
|
+
</Component>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React, { useEffect } from "react";
|
|
2
|
+
import { Portal } from "../portal";
|
|
3
|
+
import { useSelect, SelectContext } from "./context";
|
|
4
|
+
|
|
5
|
+
export interface SelectPortalProps {
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SelectPortal(props: SelectPortalProps) {
|
|
10
|
+
const select = useSelect();
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
return () => {
|
|
14
|
+
select.setOptions([]);
|
|
15
|
+
};
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
if (!select.isOpen) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Portal name="select-portal">
|
|
24
|
+
<SelectContext.Provider value={select}>{props.children}</SelectContext.Provider>
|
|
25
|
+
</Portal>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { LayoutRectangle, StyleProp, View, ViewStyle } from "react-native";
|
|
3
|
+
import { SelectContext } from "./context";
|
|
4
|
+
import { SelectOption, SelectState, SelectStyles } from "./types";
|
|
5
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
6
|
+
|
|
7
|
+
export interface SelectRootProps {
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
|
|
10
|
+
value?: string;
|
|
11
|
+
onChange?: (value: string) => void;
|
|
12
|
+
placeholder?: string;
|
|
13
|
+
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
|
|
16
|
+
render?: (props: SelectRootProps) => React.ReactElement;
|
|
17
|
+
|
|
18
|
+
styles?: SelectStyles;
|
|
19
|
+
style?: StyleProp<ViewStyle>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const calculateState = (props: SelectRootProps): SelectState => {
|
|
23
|
+
if (props.disabled) {
|
|
24
|
+
return "disabled";
|
|
25
|
+
}
|
|
26
|
+
return "default";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function SelectRoot(props: SelectRootProps) {
|
|
30
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
31
|
+
const [triggerLayout, setTriggerLayout] = useState<LayoutRectangle | null>(null);
|
|
32
|
+
const [options, setOptions] = useState<Array<SelectOption>>([]);
|
|
33
|
+
|
|
34
|
+
const state = calculateState(props);
|
|
35
|
+
const composedStyles = calculateComposedStyles(props.styles, state, "root", props.style);
|
|
36
|
+
|
|
37
|
+
const Component = props.render ?? View;
|
|
38
|
+
return (
|
|
39
|
+
<SelectContext.Provider
|
|
40
|
+
value={{
|
|
41
|
+
value: props.value ?? null,
|
|
42
|
+
onChange: props.onChange,
|
|
43
|
+
placeholder: props.placeholder ?? null,
|
|
44
|
+
isOpen,
|
|
45
|
+
setIsOpen,
|
|
46
|
+
triggerLayout,
|
|
47
|
+
setTriggerLayout,
|
|
48
|
+
options,
|
|
49
|
+
setOptions,
|
|
50
|
+
state,
|
|
51
|
+
disabled: props.disabled ?? false,
|
|
52
|
+
styles: props.styles ?? null,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Component style={composedStyles}>{props.children}</Component>
|
|
56
|
+
</SelectContext.Provider>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pressable, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { useSelect } from "./context";
|
|
4
|
+
import { calculateComposedStyles } from "../../utils/calculate-styles";
|
|
5
|
+
import { normalizeLayout } from "../../utils/normalize-layout";
|
|
6
|
+
|
|
7
|
+
interface SelectTriggerInjectionProps {
|
|
8
|
+
onPress?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SelectTriggerProps {
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
|
|
16
|
+
render?: (props: SelectTriggerInjectionProps) => React.ReactElement;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SelectTrigger(props: SelectTriggerProps) {
|
|
20
|
+
const select = useSelect();
|
|
21
|
+
const composedStyles = calculateComposedStyles(select.styles, select.state, "trigger", props.style);
|
|
22
|
+
const Component = props.render ?? Pressable;
|
|
23
|
+
return (
|
|
24
|
+
<Component
|
|
25
|
+
onPress={() => {
|
|
26
|
+
select.setIsOpen((prev) => !prev);
|
|
27
|
+
}}
|
|
28
|
+
onLayout={(e) => {
|
|
29
|
+
const layout = normalizeLayout(e.nativeEvent.layout);
|
|
30
|
+
select.setTriggerLayout(layout);
|
|
31
|
+
}}
|
|
32
|
+
style={composedStyles}
|
|
33
|
+
>
|
|
34
|
+
{props.children}
|
|
35
|
+
</Component>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { StyleProp, Text, TextStyle } from "react-native";
|
|
3
|
+
import { useSelect } from "./context";
|
|
4
|
+
|
|
5
|
+
export interface SelectValueProps {
|
|
6
|
+
style?: StyleProp<TextStyle>;
|
|
7
|
+
|
|
8
|
+
render?: (props: SelectValueProps) => React.ReactElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SelectValue(props: SelectValueProps) {
|
|
12
|
+
const select = useSelect();
|
|
13
|
+
|
|
14
|
+
const selectedOption = select.options.find((option) => option.value === select.value);
|
|
15
|
+
|
|
16
|
+
const Component = props.render ?? Text;
|
|
17
|
+
return <Component style={props.style}>{selectedOption?.label ?? select.placeholder}</Component>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SelectRootProps } from "./select-root";
|
|
2
|
+
import { SelectTriggerProps } from "./select-trigger";
|
|
3
|
+
import { SelectValueProps } from "./select-value";
|
|
4
|
+
import { SelectOverlayProps } from "./select-overlay";
|
|
5
|
+
import { SelectContentProps } from "./select-content";
|
|
6
|
+
import { SelectOptionProps } from "./select-option";
|
|
7
|
+
|
|
8
|
+
export type SelectState = "default" | "disabled";
|
|
9
|
+
|
|
10
|
+
export interface SelectStyles {
|
|
11
|
+
root?: Partial<Record<SelectState, SelectRootProps["style"]>>;
|
|
12
|
+
trigger?: Partial<Record<SelectState, SelectTriggerProps["style"]>>;
|
|
13
|
+
value?: Partial<Record<SelectState, SelectValueProps["style"]>>;
|
|
14
|
+
overlay?: Partial<Record<SelectState, SelectOverlayProps["style"]>>;
|
|
15
|
+
content?: Partial<Record<SelectState, SelectContentProps["style"]>>;
|
|
16
|
+
option?: Partial<Record<SelectState, SelectOptionProps["style"]>>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SelectOption {
|
|
20
|
+
value: string;
|
|
21
|
+
label: string;
|
|
22
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const calculateComposedStyles = <TStyle, State extends string | "default", Component extends string>(
|
|
2
|
+
styles: Partial<Record<Component, Partial<Record<State, TStyle>>>> | null = {},
|
|
3
|
+
state: State,
|
|
4
|
+
component: Component,
|
|
5
|
+
style?: TStyle
|
|
6
|
+
): TStyle[] => {
|
|
7
|
+
const result: TStyle[] = [];
|
|
8
|
+
const componentStyles = styles?.[component];
|
|
9
|
+
if (componentStyles && "default" in componentStyles && componentStyles["default"]) {
|
|
10
|
+
result.push(componentStyles["default"] as TStyle);
|
|
11
|
+
}
|
|
12
|
+
if (componentStyles?.[state]) {
|
|
13
|
+
result.push(componentStyles[state]);
|
|
14
|
+
}
|
|
15
|
+
if (style) {
|
|
16
|
+
result.push(style);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LayoutRectangle } from "react-native";
|
|
2
|
+
export const normalizeLayout = (layout: LayoutRectangle) => {
|
|
3
|
+
// Web layout doesn't provide x/y, but left/top
|
|
4
|
+
if (!layout.y && "top" in layout && typeof layout.top === "number") {
|
|
5
|
+
layout.y = layout.top;
|
|
6
|
+
}
|
|
7
|
+
if (!layout.x && "left" in layout && typeof layout.left === "number") {
|
|
8
|
+
layout.x = layout.left;
|
|
9
|
+
}
|
|
10
|
+
return layout;
|
|
11
|
+
};
|
package/tsconfig.json
ADDED