panelui-native 0.1.0 → 0.4.0
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/README.md +251 -0
- package/lib/module/components/alert/index.js +107 -24
- package/lib/module/components/alert/index.js.map +1 -1
- package/lib/module/components/avatar/index.js +1 -1
- package/lib/module/components/avatar/index.js.map +1 -1
- package/lib/module/components/badge/index.js +3 -3
- package/lib/module/components/badge/index.js.map +1 -1
- package/lib/module/components/bottom-sheet/index.js +35 -31
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/button/index.js +44 -15
- package/lib/module/components/button/index.js.map +1 -1
- package/lib/module/components/card/index.js +5 -5
- package/lib/module/components/card/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +21 -8
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/dialog/index.js +21 -17
- package/lib/module/components/dialog/index.js.map +1 -1
- package/lib/module/components/empty-state/index.js +178 -0
- package/lib/module/components/empty-state/index.js.map +1 -0
- package/lib/module/components/frame/index.js +100 -0
- package/lib/module/components/frame/index.js.map +1 -0
- package/lib/module/components/inline-select/index.js +159 -0
- package/lib/module/components/inline-select/index.js.map +1 -0
- package/lib/module/components/input/index.js +1 -1
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/input-group/index.js +132 -0
- package/lib/module/components/input-group/index.js.map +1 -0
- package/lib/module/components/label/index.js +117 -0
- package/lib/module/components/label/index.js.map +1 -0
- package/lib/module/components/progress/index.js +138 -0
- package/lib/module/components/progress/index.js.map +1 -0
- package/lib/module/components/select/index.js +35 -28
- package/lib/module/components/select/index.js.map +1 -1
- package/lib/module/components/skeleton/index.js +1 -1
- package/lib/module/components/skeleton/index.js.map +1 -1
- package/lib/module/components/spinner/index.js +1 -1
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/steps/index.js +354 -0
- package/lib/module/components/steps/index.js.map +1 -0
- package/lib/module/components/tabs/index.js +9 -3
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/toast/index.js +479 -0
- package/lib/module/components/toast/index.js.map +1 -0
- package/lib/module/components/toast/toast-store.js +81 -0
- package/lib/module/components/toast/toast-store.js.map +1 -0
- package/lib/module/components/typography/index.js +126 -0
- package/lib/module/components/typography/index.js.map +1 -0
- package/lib/module/icons/index.js +223 -2
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +11 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/providers/panel-ui-provider.js +20 -8
- package/lib/module/providers/panel-ui-provider.js.map +1 -1
- package/lib/module/theme/use-theme.js +90 -3
- package/lib/module/theme/use-theme.js.map +1 -1
- package/lib/typescript/src/components/alert/index.d.ts +41 -19
- package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/button/index.d.ts +39 -0
- package/lib/typescript/src/components/button/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
- package/lib/typescript/src/components/empty-state/index.d.ts +73 -0
- package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +21 -0
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -0
- package/lib/typescript/src/components/inline-select/index.d.ts +25 -0
- package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -0
- package/lib/typescript/src/components/input-group/index.d.ts +34 -0
- package/lib/typescript/src/components/input-group/index.d.ts.map +1 -0
- package/lib/typescript/src/components/label/index.d.ts +28 -0
- package/lib/typescript/src/components/label/index.d.ts.map +1 -0
- package/lib/typescript/src/components/progress/index.d.ts +132 -0
- package/lib/typescript/src/components/progress/index.d.ts.map +1 -0
- package/lib/typescript/src/components/select/index.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +2 -2
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/steps/index.d.ts +62 -0
- package/lib/typescript/src/components/steps/index.d.ts.map +1 -0
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/toast/index.d.ts +176 -0
- package/lib/typescript/src/components/toast/index.d.ts.map +1 -0
- package/lib/typescript/src/components/toast/toast-store.d.ts +65 -0
- package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -0
- package/lib/typescript/src/components/typography/index.d.ts +77 -0
- package/lib/typescript/src/components/typography/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +25 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +12 -3
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/providers/panel-ui-provider.d.ts +20 -5
- package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
- package/lib/typescript/src/theme/use-theme.d.ts +64 -4
- package/lib/typescript/src/theme/use-theme.d.ts.map +1 -1
- package/package.json +28 -5
- package/src/components/alert/index.tsx +104 -28
- package/src/components/avatar/index.tsx +1 -1
- package/src/components/badge/index.tsx +3 -3
- package/src/components/bottom-sheet/index.tsx +8 -3
- package/src/components/button/index.tsx +47 -7
- package/src/components/card/index.tsx +5 -5
- package/src/components/checkbox/index.tsx +18 -4
- package/src/components/dialog/index.tsx +10 -5
- package/src/components/empty-state/index.tsx +136 -0
- package/src/components/frame/index.tsx +104 -0
- package/src/components/inline-select/index.tsx +185 -0
- package/src/components/input/index.tsx +1 -1
- package/src/components/input-group/index.tsx +164 -0
- package/src/components/label/index.tsx +128 -0
- package/src/components/progress/index.tsx +147 -0
- package/src/components/select/index.tsx +30 -23
- package/src/components/skeleton/index.tsx +1 -1
- package/src/components/spinner/index.tsx +1 -1
- package/src/components/steps/index.tsx +343 -0
- package/src/components/tabs/index.tsx +10 -2
- package/src/components/toast/index.tsx +447 -0
- package/src/components/toast/toast-store.ts +120 -0
- package/src/components/typography/index.tsx +121 -0
- package/src/icons/index.tsx +152 -1
- package/src/index.ts +82 -3
- package/src/providers/panel-ui-provider.tsx +35 -10
- package/src/theme/use-theme.ts +119 -4
- package/theme.css +446 -108
|
@@ -16,7 +16,9 @@ const checkboxVariants = tv({
|
|
|
16
16
|
row: 'flex-row items-center gap-2.5',
|
|
17
17
|
box: 'h-5 w-5 items-center justify-center rounded-md border border-input bg-background',
|
|
18
18
|
fill: 'absolute inset-0 items-center justify-center rounded-md bg-primary',
|
|
19
|
+
content: 'flex-1 gap-0.5',
|
|
19
20
|
label: 'text-sm text-foreground',
|
|
21
|
+
description: 'text-sm text-muted-foreground',
|
|
20
22
|
},
|
|
21
23
|
variants: {
|
|
22
24
|
disabled: {
|
|
@@ -32,13 +34,15 @@ export interface CheckboxProps extends VariantProps<typeof checkboxVariants> {
|
|
|
32
34
|
disabled?: boolean;
|
|
33
35
|
/** Optional label rendered next to the box; pressing it also toggles. */
|
|
34
36
|
label?: string;
|
|
37
|
+
/** Secondary line under the label, for extra context. */
|
|
38
|
+
description?: string;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export const Checkbox = forwardRef<View, CheckboxProps>(
|
|
38
|
-
({ className, checked, onCheckedChange, disabled, label }, ref) => {
|
|
42
|
+
({ className, checked, onCheckedChange, disabled, label, description }, ref) => {
|
|
39
43
|
const progress = useSharedValue(checked ? 1 : 0);
|
|
40
44
|
const slots = checkboxVariants({ disabled: !!disabled });
|
|
41
|
-
const checkColor = useCSSVariable('--primary-foreground');
|
|
45
|
+
const checkColor = useCSSVariable('--color-primary-foreground');
|
|
42
46
|
|
|
43
47
|
useEffect(() => {
|
|
44
48
|
progress.value = checked
|
|
@@ -57,10 +61,13 @@ export const Checkbox = forwardRef<View, CheckboxProps>(
|
|
|
57
61
|
accessibilityRole="checkbox"
|
|
58
62
|
accessibilityState={{ checked, disabled: !!disabled }}
|
|
59
63
|
accessibilityLabel={label}
|
|
64
|
+
accessibilityHint={description}
|
|
60
65
|
disabled={disabled}
|
|
61
66
|
onPress={() => onCheckedChange?.(!checked)}
|
|
62
67
|
hitSlop={8}
|
|
63
|
-
|
|
68
|
+
// A description makes the row two lines tall — align the box to the
|
|
69
|
+
// label rather than centring it against the whole block.
|
|
70
|
+
className={slots.row({ className: description ? `items-start ${className ?? ''}` : className })}
|
|
64
71
|
>
|
|
65
72
|
<View className={slots.box()}>
|
|
66
73
|
<Animated.View style={fillStyle} className={slots.fill()}>
|
|
@@ -70,7 +77,14 @@ export const Checkbox = forwardRef<View, CheckboxProps>(
|
|
|
70
77
|
/>
|
|
71
78
|
</Animated.View>
|
|
72
79
|
</View>
|
|
73
|
-
{label ?
|
|
80
|
+
{label || description ? (
|
|
81
|
+
<View className={slots.content()}>
|
|
82
|
+
{label ? <Text className={slots.label()}>{label}</Text> : null}
|
|
83
|
+
{description ? (
|
|
84
|
+
<Text className={slots.description()}>{description}</Text>
|
|
85
|
+
) : null}
|
|
86
|
+
</View>
|
|
87
|
+
) : null}
|
|
74
88
|
</Pressable>
|
|
75
89
|
);
|
|
76
90
|
}
|
|
@@ -91,13 +91,17 @@ function DialogContent({
|
|
|
91
91
|
children,
|
|
92
92
|
...props
|
|
93
93
|
}: DialogContentProps) {
|
|
94
|
-
const
|
|
94
|
+
const context = useDialog('Dialog.Content');
|
|
95
|
+
const { open, setOpen } = context;
|
|
95
96
|
|
|
96
97
|
if (!open) return null;
|
|
97
98
|
|
|
98
99
|
return (
|
|
99
100
|
<Portal>
|
|
100
|
-
|
|
101
|
+
{/* Portal content mounts under PortalHost, outside this provider's
|
|
102
|
+
subtree — re-provide the context so Dialog.Close etc. keep working. */}
|
|
103
|
+
<DialogContext.Provider value={context}>
|
|
104
|
+
<Animated.View
|
|
101
105
|
entering={FadeIn.duration(150)}
|
|
102
106
|
exiting={FadeOut.duration(150)}
|
|
103
107
|
className="absolute inset-0 items-center justify-center p-6"
|
|
@@ -116,10 +120,11 @@ function DialogContent({
|
|
|
116
120
|
className
|
|
117
121
|
)}
|
|
118
122
|
{...props}
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
>
|
|
124
|
+
{children}
|
|
125
|
+
</Animated.View>
|
|
121
126
|
</Animated.View>
|
|
122
|
-
</
|
|
127
|
+
</DialogContext.Provider>
|
|
123
128
|
</Portal>
|
|
124
129
|
);
|
|
125
130
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmptyState — the placeholder shown when a list or view has no content.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: cosscom/coss packages/ui/src/components/empty.tsx
|
|
5
|
+
* (centered Header / Media / Title / Description / Content anatomy, and the
|
|
6
|
+
* `icon` media variant that stacks two rotated ghost cards behind the icon).
|
|
7
|
+
* Coss does that with CSS pseudo-elements; on native it is two absolutely
|
|
8
|
+
* positioned siblings with static rotate transforms.
|
|
9
|
+
*/
|
|
10
|
+
import { forwardRef, type ReactNode } from 'react';
|
|
11
|
+
import { View, type Text as RNText, type ViewProps } from 'react-native';
|
|
12
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
13
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
14
|
+
|
|
15
|
+
const emptyStateVariants = tv({
|
|
16
|
+
slots: {
|
|
17
|
+
root: 'w-full flex-1 items-center justify-center gap-6 px-6 py-12',
|
|
18
|
+
header: 'w-full max-w-sm items-center gap-1',
|
|
19
|
+
title: 'text-center text-xl font-semibold text-foreground',
|
|
20
|
+
description: 'text-center text-sm text-muted-foreground',
|
|
21
|
+
content: 'w-full max-w-sm items-center gap-3',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const mediaVariants = tv({
|
|
26
|
+
slots: {
|
|
27
|
+
wrapper: 'items-center justify-center',
|
|
28
|
+
card: 'h-9 w-9 shrink-0 items-center justify-center',
|
|
29
|
+
ghost: 'absolute h-9 w-9 rounded-md border border-border bg-card',
|
|
30
|
+
},
|
|
31
|
+
variants: {
|
|
32
|
+
variant: {
|
|
33
|
+
default: {
|
|
34
|
+
card: 'bg-transparent',
|
|
35
|
+
ghost: 'hidden',
|
|
36
|
+
},
|
|
37
|
+
icon: {
|
|
38
|
+
card: 'rounded-md border border-border bg-card',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
defaultVariants: {
|
|
43
|
+
variant: 'default',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export interface EmptyStateProps extends ViewProps {
|
|
48
|
+
className?: string;
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const EmptyStateRoot = forwardRef<View, EmptyStateProps>(
|
|
53
|
+
({ className, ...props }, ref) => {
|
|
54
|
+
const { root } = emptyStateVariants();
|
|
55
|
+
return <View ref={ref} className={root({ className })} {...props} />;
|
|
56
|
+
}
|
|
57
|
+
);
|
|
58
|
+
EmptyStateRoot.displayName = 'EmptyState';
|
|
59
|
+
|
|
60
|
+
/** Groups Media, Title and Description into one centered column. */
|
|
61
|
+
const EmptyStateHeader = forwardRef<View, EmptyStateProps>(
|
|
62
|
+
({ className, ...props }, ref) => {
|
|
63
|
+
const { header } = emptyStateVariants();
|
|
64
|
+
return <View ref={ref} className={header({ className })} {...props} />;
|
|
65
|
+
}
|
|
66
|
+
);
|
|
67
|
+
EmptyStateHeader.displayName = 'EmptyState.Header';
|
|
68
|
+
|
|
69
|
+
export interface EmptyStateMediaProps
|
|
70
|
+
extends ViewProps,
|
|
71
|
+
VariantProps<typeof mediaVariants> {
|
|
72
|
+
className?: string;
|
|
73
|
+
children?: ReactNode;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Visual anchor above the title. `variant="icon"` frames the child in a small
|
|
78
|
+
* card with two rotated ghost cards fanned out behind it.
|
|
79
|
+
*/
|
|
80
|
+
const EmptyStateMedia = forwardRef<View, EmptyStateMediaProps>(
|
|
81
|
+
({ className, variant = 'default', children, ...props }, ref) => {
|
|
82
|
+
const { wrapper, card, ghost } = mediaVariants({ variant });
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<View ref={ref} className={wrapper({ className: `mb-4 ${className ?? ''}` })} {...props}>
|
|
86
|
+
{variant === 'icon' ? (
|
|
87
|
+
<>
|
|
88
|
+
<View
|
|
89
|
+
accessibilityElementsHidden
|
|
90
|
+
importantForAccessibility="no-hide-descendants"
|
|
91
|
+
className={`${ghost()} -translate-x-2 -rotate-[10deg] scale-90`}
|
|
92
|
+
/>
|
|
93
|
+
<View
|
|
94
|
+
accessibilityElementsHidden
|
|
95
|
+
importantForAccessibility="no-hide-descendants"
|
|
96
|
+
className={`${ghost()} translate-x-2 rotate-[10deg] scale-90`}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
) : null}
|
|
100
|
+
<View className={card()}>{children}</View>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
EmptyStateMedia.displayName = 'EmptyState.Media';
|
|
106
|
+
|
|
107
|
+
const EmptyStateTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
|
|
108
|
+
const { title } = emptyStateVariants();
|
|
109
|
+
return <Text ref={ref} className={title({ className })} {...props} />;
|
|
110
|
+
});
|
|
111
|
+
EmptyStateTitle.displayName = 'EmptyState.Title';
|
|
112
|
+
|
|
113
|
+
const EmptyStateDescription = forwardRef<RNText, TextProps>(
|
|
114
|
+
({ className, ...props }, ref) => {
|
|
115
|
+
const { description } = emptyStateVariants();
|
|
116
|
+
return <Text ref={ref} className={description({ className })} {...props} />;
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
EmptyStateDescription.displayName = 'EmptyState.Description';
|
|
120
|
+
|
|
121
|
+
/** Slot below the header, for actions such as a primary button. */
|
|
122
|
+
const EmptyStateContent = forwardRef<View, EmptyStateProps>(
|
|
123
|
+
({ className, ...props }, ref) => {
|
|
124
|
+
const { content } = emptyStateVariants();
|
|
125
|
+
return <View ref={ref} className={content({ className })} {...props} />;
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
EmptyStateContent.displayName = 'EmptyState.Content';
|
|
129
|
+
|
|
130
|
+
export const EmptyState = Object.assign(EmptyStateRoot, {
|
|
131
|
+
Header: EmptyStateHeader,
|
|
132
|
+
Media: EmptyStateMedia,
|
|
133
|
+
Title: EmptyStateTitle,
|
|
134
|
+
Description: EmptyStateDescription,
|
|
135
|
+
Content: EmptyStateContent,
|
|
136
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { forwardRef } from 'react';
|
|
2
|
+
import { View, type ViewProps } from 'react-native';
|
|
3
|
+
import { Text, type TextProps } from '../../primitives/text';
|
|
4
|
+
import { cn } from '../../utils/cn';
|
|
5
|
+
|
|
6
|
+
export interface FrameProps extends ViewProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Grouping container (Coss UI's CardFrame): a tinted, rounded surface that
|
|
12
|
+
* holds a header, one or more inset panels, and a footer. Use it to group
|
|
13
|
+
* related settings or list sections under a single title.
|
|
14
|
+
*/
|
|
15
|
+
const FrameRoot = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
|
|
16
|
+
<View
|
|
17
|
+
ref={ref}
|
|
18
|
+
className={cn('gap-2 rounded-2xl border border-border bg-surface py-2', className)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
));
|
|
22
|
+
FrameRoot.displayName = 'Frame';
|
|
23
|
+
|
|
24
|
+
const FrameHeader = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
|
|
25
|
+
<View ref={ref} className={cn('gap-1 px-5 py-2', className)} {...props} />
|
|
26
|
+
));
|
|
27
|
+
FrameHeader.displayName = 'Frame.Header';
|
|
28
|
+
|
|
29
|
+
const FrameTitle = forwardRef<React.ElementRef<typeof Text>, TextProps>(
|
|
30
|
+
({ className, ...props }, ref) => (
|
|
31
|
+
<Text
|
|
32
|
+
ref={ref}
|
|
33
|
+
size="base"
|
|
34
|
+
weight="semibold"
|
|
35
|
+
className={cn('text-foreground', className)}
|
|
36
|
+
{...props}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
);
|
|
40
|
+
FrameTitle.displayName = 'Frame.Title';
|
|
41
|
+
|
|
42
|
+
const FrameDescription = forwardRef<React.ElementRef<typeof Text>, TextProps>(
|
|
43
|
+
({ className, ...props }, ref) => (
|
|
44
|
+
<Text ref={ref} size="sm" muted className={className} {...props} />
|
|
45
|
+
)
|
|
46
|
+
);
|
|
47
|
+
FrameDescription.displayName = 'Frame.Description';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* An inset card surface inside a Frame — the raised panel that holds the
|
|
51
|
+
* frame's actual content, sitting slightly in from the frame's edges.
|
|
52
|
+
*/
|
|
53
|
+
const FramePanel = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
|
|
54
|
+
<View
|
|
55
|
+
ref={ref}
|
|
56
|
+
className={cn(
|
|
57
|
+
'mx-1.5 overflow-hidden rounded-xl border border-border bg-card',
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
));
|
|
63
|
+
FramePanel.displayName = 'Frame.Panel';
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A row inside a Frame.Panel. Pass `divided` on every row after the first to
|
|
67
|
+
* draw a hairline separator above it (React Native has no :first-child).
|
|
68
|
+
*/
|
|
69
|
+
export interface FrameRowProps extends FrameProps {
|
|
70
|
+
divided?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const FrameRow = forwardRef<View, FrameRowProps>(
|
|
74
|
+
({ className, divided, ...props }, ref) => (
|
|
75
|
+
<View
|
|
76
|
+
ref={ref}
|
|
77
|
+
className={cn(
|
|
78
|
+
'flex-row items-center gap-3 px-4 py-3.5',
|
|
79
|
+
divided && 'border-t border-border',
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
FrameRow.displayName = 'Frame.Row';
|
|
87
|
+
|
|
88
|
+
const FrameFooter = forwardRef<View, FrameProps>(({ className, ...props }, ref) => (
|
|
89
|
+
<View
|
|
90
|
+
ref={ref}
|
|
91
|
+
className={cn('flex-row items-center gap-2 px-5 py-2', className)}
|
|
92
|
+
{...props}
|
|
93
|
+
/>
|
|
94
|
+
));
|
|
95
|
+
FrameFooter.displayName = 'Frame.Footer';
|
|
96
|
+
|
|
97
|
+
export const Frame = Object.assign(FrameRoot, {
|
|
98
|
+
Header: FrameHeader,
|
|
99
|
+
Title: FrameTitle,
|
|
100
|
+
Description: FrameDescription,
|
|
101
|
+
Panel: FramePanel,
|
|
102
|
+
Row: FrameRow,
|
|
103
|
+
Footer: FrameFooter,
|
|
104
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
isValidElement,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import { Pressable, View } from 'react-native';
|
|
12
|
+
import Animated, {
|
|
13
|
+
FadeIn,
|
|
14
|
+
FadeOut,
|
|
15
|
+
useAnimatedStyle,
|
|
16
|
+
useSharedValue,
|
|
17
|
+
withTiming,
|
|
18
|
+
} from 'react-native-reanimated';
|
|
19
|
+
import { tv } from 'tailwind-variants';
|
|
20
|
+
import { useCSSVariable } from 'uniwind';
|
|
21
|
+
import { CheckIcon, ChevronDownIcon } from '../../icons';
|
|
22
|
+
import { Text } from '../../primitives/text';
|
|
23
|
+
|
|
24
|
+
const inlineSelectVariants = tv({
|
|
25
|
+
slots: {
|
|
26
|
+
trigger:
|
|
27
|
+
'w-full flex-row items-center justify-between gap-3 rounded-2xl border border-input bg-background px-4 py-3.5',
|
|
28
|
+
triggerLabel: 'flex-1 text-base font-medium text-foreground',
|
|
29
|
+
placeholder: 'flex-1 text-base text-muted-foreground',
|
|
30
|
+
list: 'mt-2 overflow-hidden rounded-2xl border border-border bg-popover p-2 shadow-sm',
|
|
31
|
+
item: 'flex-row items-center gap-2 rounded-xl px-3 py-3',
|
|
32
|
+
itemLabel: 'flex-1 text-base font-medium text-foreground',
|
|
33
|
+
itemIndicator: 'h-5 w-5 items-center justify-center',
|
|
34
|
+
},
|
|
35
|
+
variants: {
|
|
36
|
+
selected: {
|
|
37
|
+
true: { item: 'bg-accent' },
|
|
38
|
+
},
|
|
39
|
+
disabled: {
|
|
40
|
+
true: { trigger: 'opacity-[0.64]' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
interface InlineSelectContextValue {
|
|
46
|
+
value: string | undefined;
|
|
47
|
+
onSelect: (value: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const InlineSelectContext = createContext<InlineSelectContextValue | null>(null);
|
|
51
|
+
|
|
52
|
+
export interface InlineSelectItemProps {
|
|
53
|
+
value: string;
|
|
54
|
+
label: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Option row. Rendered inline beneath the trigger — no overlay involved. */
|
|
58
|
+
function InlineSelectItem({ value, label }: InlineSelectItemProps) {
|
|
59
|
+
const context = useContext(InlineSelectContext);
|
|
60
|
+
if (!context) {
|
|
61
|
+
throw new Error('InlineSelect.Item must be used within an <InlineSelect>');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const selected = context.value === value;
|
|
65
|
+
const { item, itemLabel, itemIndicator } = inlineSelectVariants({ selected });
|
|
66
|
+
const checkColor = useCSSVariable('--color-muted-foreground');
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<Pressable
|
|
70
|
+
accessibilityRole="menuitem"
|
|
71
|
+
accessibilityState={{ selected }}
|
|
72
|
+
onPress={() => context.onSelect(value)}
|
|
73
|
+
className={item()}
|
|
74
|
+
>
|
|
75
|
+
<Text className={itemLabel()}>{label}</Text>
|
|
76
|
+
<View className={itemIndicator()}>
|
|
77
|
+
{selected ? (
|
|
78
|
+
<CheckIcon
|
|
79
|
+
size={16}
|
|
80
|
+
color={typeof checkColor === 'string' ? checkColor : '#737373'}
|
|
81
|
+
/>
|
|
82
|
+
) : null}
|
|
83
|
+
</View>
|
|
84
|
+
</Pressable>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface InlineSelectProps {
|
|
89
|
+
className?: string;
|
|
90
|
+
value?: string;
|
|
91
|
+
onValueChange: (value: string) => void;
|
|
92
|
+
placeholder?: string;
|
|
93
|
+
disabled?: boolean;
|
|
94
|
+
children: ReactNode;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A select that expands its options inline instead of opening a bottom sheet.
|
|
99
|
+
* Useful inside forms and scroll views where an overlay would be disruptive.
|
|
100
|
+
*/
|
|
101
|
+
function InlineSelectRoot({
|
|
102
|
+
className,
|
|
103
|
+
value,
|
|
104
|
+
onValueChange,
|
|
105
|
+
placeholder = 'Select an option',
|
|
106
|
+
disabled,
|
|
107
|
+
children,
|
|
108
|
+
}: InlineSelectProps) {
|
|
109
|
+
const [open, setOpen] = useState(false);
|
|
110
|
+
const chevron = useSharedValue(0);
|
|
111
|
+
|
|
112
|
+
const selectedLabel = useMemo(() => {
|
|
113
|
+
let label: string | undefined;
|
|
114
|
+
Children.forEach(children, (child) => {
|
|
115
|
+
if (isValidElement<InlineSelectItemProps>(child) && child.props.value === value) {
|
|
116
|
+
label = child.props.label;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return label;
|
|
120
|
+
}, [children, value]);
|
|
121
|
+
|
|
122
|
+
const toggle = useCallback(() => {
|
|
123
|
+
setOpen((current) => {
|
|
124
|
+
chevron.value = withTiming(current ? 0 : 1, { duration: 160 });
|
|
125
|
+
return !current;
|
|
126
|
+
});
|
|
127
|
+
}, [chevron]);
|
|
128
|
+
|
|
129
|
+
const context = useMemo<InlineSelectContextValue>(
|
|
130
|
+
() => ({
|
|
131
|
+
value,
|
|
132
|
+
onSelect: (next) => {
|
|
133
|
+
onValueChange(next);
|
|
134
|
+
chevron.value = withTiming(0, { duration: 160 });
|
|
135
|
+
setOpen(false);
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
[value, onValueChange, chevron]
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const slots = inlineSelectVariants({ disabled: !!disabled });
|
|
142
|
+
const chevronColor = useCSSVariable('--color-muted-foreground');
|
|
143
|
+
|
|
144
|
+
const chevronStyle = useAnimatedStyle(() => ({
|
|
145
|
+
transform: [{ rotate: `${chevron.value * 180}deg` }],
|
|
146
|
+
}));
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<InlineSelectContext.Provider value={context}>
|
|
150
|
+
<View className={className}>
|
|
151
|
+
<Pressable
|
|
152
|
+
accessibilityRole="button"
|
|
153
|
+
accessibilityState={{ disabled: !!disabled, expanded: open }}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
onPress={toggle}
|
|
156
|
+
className={slots.trigger()}
|
|
157
|
+
>
|
|
158
|
+
{selectedLabel ? (
|
|
159
|
+
<Text className={slots.triggerLabel()}>{selectedLabel}</Text>
|
|
160
|
+
) : (
|
|
161
|
+
<Text className={slots.placeholder()}>{placeholder}</Text>
|
|
162
|
+
)}
|
|
163
|
+
<Animated.View style={chevronStyle}>
|
|
164
|
+
<ChevronDownIcon
|
|
165
|
+
color={typeof chevronColor === 'string' ? chevronColor : '#737373'}
|
|
166
|
+
/>
|
|
167
|
+
</Animated.View>
|
|
168
|
+
</Pressable>
|
|
169
|
+
{open ? (
|
|
170
|
+
<Animated.View
|
|
171
|
+
entering={FadeIn.duration(140)}
|
|
172
|
+
exiting={FadeOut.duration(120)}
|
|
173
|
+
className={slots.list()}
|
|
174
|
+
>
|
|
175
|
+
{children}
|
|
176
|
+
</Animated.View>
|
|
177
|
+
) : null}
|
|
178
|
+
</View>
|
|
179
|
+
</InlineSelectContext.Provider>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const InlineSelect = Object.assign(InlineSelectRoot, {
|
|
184
|
+
Item: InlineSelectItem,
|
|
185
|
+
});
|
|
@@ -52,7 +52,7 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
52
52
|
ref
|
|
53
53
|
) => {
|
|
54
54
|
const [focused, setFocused] = useState(false);
|
|
55
|
-
const placeholderColor = useCSSVariable('--muted-foreground');
|
|
55
|
+
const placeholderColor = useCSSVariable('--color-muted-foreground');
|
|
56
56
|
const slots = inputVariants({
|
|
57
57
|
focused,
|
|
58
58
|
invalid: !!errorMessage,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputGroup — an Input with leading and/or trailing decorators.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from: heroui-inc/heroui-native src/components/input-group/
|
|
5
|
+
* Prefix and Suffix are absolutely positioned and their measured widths become
|
|
6
|
+
* padding on the Input, so text never runs underneath a decorator regardless
|
|
7
|
+
* of how wide that decorator turns out to be.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
forwardRef,
|
|
12
|
+
useCallback,
|
|
13
|
+
useContext,
|
|
14
|
+
useMemo,
|
|
15
|
+
useState,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
} from 'react';
|
|
18
|
+
import {
|
|
19
|
+
View,
|
|
20
|
+
type LayoutChangeEvent,
|
|
21
|
+
type TextInput,
|
|
22
|
+
type ViewProps,
|
|
23
|
+
} from 'react-native';
|
|
24
|
+
import { tv } from 'tailwind-variants';
|
|
25
|
+
import { Input, type InputProps } from '../input';
|
|
26
|
+
|
|
27
|
+
const inputGroupVariants = tv({
|
|
28
|
+
slots: {
|
|
29
|
+
root: 'w-full',
|
|
30
|
+
prefix: 'absolute bottom-0 left-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',
|
|
31
|
+
suffix: 'absolute bottom-0 right-0 top-0 z-10 flex-row items-center justify-center gap-2 px-3',
|
|
32
|
+
},
|
|
33
|
+
variants: {
|
|
34
|
+
isDisabled: {
|
|
35
|
+
true: { prefix: 'opacity-[0.64]', suffix: 'opacity-[0.64]' },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
interface InputGroupState {
|
|
41
|
+
isDisabled: boolean;
|
|
42
|
+
prefixWidth: number;
|
|
43
|
+
suffixWidth: number;
|
|
44
|
+
setPrefixWidth: (width: number) => void;
|
|
45
|
+
setSuffixWidth: (width: number) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const InputGroupContext = createContext<InputGroupState | null>(null);
|
|
49
|
+
|
|
50
|
+
function useInputGroup() {
|
|
51
|
+
return useContext(InputGroupContext);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InputGroupProps extends ViewProps {
|
|
55
|
+
className?: string;
|
|
56
|
+
/** Disables the input and dims both decorators. */
|
|
57
|
+
isDisabled?: boolean;
|
|
58
|
+
children?: ReactNode;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const InputGroupRoot = forwardRef<View, InputGroupProps>(
|
|
62
|
+
({ className, isDisabled = false, children, ...props }, ref) => {
|
|
63
|
+
const { root } = inputGroupVariants();
|
|
64
|
+
const [prefixWidth, setPrefixWidth] = useState(0);
|
|
65
|
+
const [suffixWidth, setSuffixWidth] = useState(0);
|
|
66
|
+
|
|
67
|
+
const value = useMemo<InputGroupState>(
|
|
68
|
+
() => ({ isDisabled, prefixWidth, suffixWidth, setPrefixWidth, setSuffixWidth }),
|
|
69
|
+
[isDisabled, prefixWidth, suffixWidth]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<InputGroupContext.Provider value={value}>
|
|
74
|
+
<View ref={ref} className={root({ className })} {...props}>
|
|
75
|
+
{children}
|
|
76
|
+
</View>
|
|
77
|
+
</InputGroupContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
InputGroupRoot.displayName = 'InputGroup';
|
|
82
|
+
|
|
83
|
+
export interface InputGroupDecoratorProps extends ViewProps {
|
|
84
|
+
className?: string;
|
|
85
|
+
/**
|
|
86
|
+
* Marks the decorator as presentation-only: touches fall through to the
|
|
87
|
+
* Input and screen readers skip it. Leave it off when the decorator holds
|
|
88
|
+
* something interactive, such as a show-password toggle.
|
|
89
|
+
*/
|
|
90
|
+
isDecorative?: boolean;
|
|
91
|
+
children?: ReactNode;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Builds Prefix/Suffix, which differ only in which side they measure. */
|
|
95
|
+
function createDecorator(side: 'prefix' | 'suffix') {
|
|
96
|
+
const Decorator = forwardRef<View, InputGroupDecoratorProps>(
|
|
97
|
+
({ className, isDecorative = false, onLayout, children, ...props }, ref) => {
|
|
98
|
+
const group = useInputGroup();
|
|
99
|
+
const slots = inputGroupVariants({ isDisabled: group?.isDisabled ?? false });
|
|
100
|
+
const setWidth = side === 'prefix' ? group?.setPrefixWidth : group?.setSuffixWidth;
|
|
101
|
+
|
|
102
|
+
const handleLayout = useCallback(
|
|
103
|
+
(event: LayoutChangeEvent) => {
|
|
104
|
+
setWidth?.(event.nativeEvent.layout.width);
|
|
105
|
+
onLayout?.(event);
|
|
106
|
+
},
|
|
107
|
+
[setWidth, onLayout]
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<View
|
|
112
|
+
ref={ref}
|
|
113
|
+
onLayout={handleLayout}
|
|
114
|
+
pointerEvents={isDecorative ? 'none' : 'auto'}
|
|
115
|
+
accessibilityElementsHidden={isDecorative}
|
|
116
|
+
importantForAccessibility={isDecorative ? 'no-hide-descendants' : 'auto'}
|
|
117
|
+
className={slots[side]({ className })}
|
|
118
|
+
{...props}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
);
|
|
125
|
+
Decorator.displayName = side === 'prefix' ? 'InputGroup.Prefix' : 'InputGroup.Suffix';
|
|
126
|
+
return Decorator;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const InputGroupPrefix = createDecorator('prefix');
|
|
130
|
+
const InputGroupSuffix = createDecorator('suffix');
|
|
131
|
+
|
|
132
|
+
export type InputGroupInputProps = InputProps;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The Input itself. Receives left/right padding matching the measured
|
|
136
|
+
* decorator widths, so its text always clears them.
|
|
137
|
+
*/
|
|
138
|
+
const InputGroupInput = forwardRef<TextInput, InputGroupInputProps>(
|
|
139
|
+
({ style, disabled, ...props }, ref) => {
|
|
140
|
+
const group = useInputGroup();
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<Input
|
|
144
|
+
ref={ref}
|
|
145
|
+
disabled={disabled ?? group?.isDisabled}
|
|
146
|
+
style={[
|
|
147
|
+
// Only override padding on the side that actually has a decorator —
|
|
148
|
+
// a 0 here would wipe out the field's default horizontal padding.
|
|
149
|
+
group?.prefixWidth ? { paddingLeft: group.prefixWidth } : null,
|
|
150
|
+
group?.suffixWidth ? { paddingRight: group.suffixWidth } : null,
|
|
151
|
+
style,
|
|
152
|
+
]}
|
|
153
|
+
{...props}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
);
|
|
158
|
+
InputGroupInput.displayName = 'InputGroup.Input';
|
|
159
|
+
|
|
160
|
+
export const InputGroup = Object.assign(InputGroupRoot, {
|
|
161
|
+
Prefix: InputGroupPrefix,
|
|
162
|
+
Input: InputGroupInput,
|
|
163
|
+
Suffix: InputGroupSuffix,
|
|
164
|
+
});
|