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.
Files changed (124) hide show
  1. package/README.md +251 -0
  2. package/lib/module/components/alert/index.js +107 -24
  3. package/lib/module/components/alert/index.js.map +1 -1
  4. package/lib/module/components/avatar/index.js +1 -1
  5. package/lib/module/components/avatar/index.js.map +1 -1
  6. package/lib/module/components/badge/index.js +3 -3
  7. package/lib/module/components/badge/index.js.map +1 -1
  8. package/lib/module/components/bottom-sheet/index.js +35 -31
  9. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  10. package/lib/module/components/button/index.js +44 -15
  11. package/lib/module/components/button/index.js.map +1 -1
  12. package/lib/module/components/card/index.js +5 -5
  13. package/lib/module/components/card/index.js.map +1 -1
  14. package/lib/module/components/checkbox/index.js +21 -8
  15. package/lib/module/components/checkbox/index.js.map +1 -1
  16. package/lib/module/components/dialog/index.js +21 -17
  17. package/lib/module/components/dialog/index.js.map +1 -1
  18. package/lib/module/components/empty-state/index.js +178 -0
  19. package/lib/module/components/empty-state/index.js.map +1 -0
  20. package/lib/module/components/frame/index.js +100 -0
  21. package/lib/module/components/frame/index.js.map +1 -0
  22. package/lib/module/components/inline-select/index.js +159 -0
  23. package/lib/module/components/inline-select/index.js.map +1 -0
  24. package/lib/module/components/input/index.js +1 -1
  25. package/lib/module/components/input/index.js.map +1 -1
  26. package/lib/module/components/input-group/index.js +132 -0
  27. package/lib/module/components/input-group/index.js.map +1 -0
  28. package/lib/module/components/label/index.js +117 -0
  29. package/lib/module/components/label/index.js.map +1 -0
  30. package/lib/module/components/progress/index.js +138 -0
  31. package/lib/module/components/progress/index.js.map +1 -0
  32. package/lib/module/components/select/index.js +35 -28
  33. package/lib/module/components/select/index.js.map +1 -1
  34. package/lib/module/components/skeleton/index.js +1 -1
  35. package/lib/module/components/skeleton/index.js.map +1 -1
  36. package/lib/module/components/spinner/index.js +1 -1
  37. package/lib/module/components/spinner/index.js.map +1 -1
  38. package/lib/module/components/steps/index.js +354 -0
  39. package/lib/module/components/steps/index.js.map +1 -0
  40. package/lib/module/components/tabs/index.js +9 -3
  41. package/lib/module/components/tabs/index.js.map +1 -1
  42. package/lib/module/components/toast/index.js +479 -0
  43. package/lib/module/components/toast/index.js.map +1 -0
  44. package/lib/module/components/toast/toast-store.js +81 -0
  45. package/lib/module/components/toast/toast-store.js.map +1 -0
  46. package/lib/module/components/typography/index.js +126 -0
  47. package/lib/module/components/typography/index.js.map +1 -0
  48. package/lib/module/icons/index.js +223 -2
  49. package/lib/module/icons/index.js.map +1 -1
  50. package/lib/module/index.js +11 -2
  51. package/lib/module/index.js.map +1 -1
  52. package/lib/module/providers/panel-ui-provider.js +20 -8
  53. package/lib/module/providers/panel-ui-provider.js.map +1 -1
  54. package/lib/module/theme/use-theme.js +90 -3
  55. package/lib/module/theme/use-theme.js.map +1 -1
  56. package/lib/typescript/src/components/alert/index.d.ts +41 -19
  57. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/button/index.d.ts +39 -0
  60. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  62. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/empty-state/index.d.ts +73 -0
  65. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -0
  66. package/lib/typescript/src/components/frame/index.d.ts +21 -0
  67. package/lib/typescript/src/components/frame/index.d.ts.map +1 -0
  68. package/lib/typescript/src/components/inline-select/index.d.ts +25 -0
  69. package/lib/typescript/src/components/inline-select/index.d.ts.map +1 -0
  70. package/lib/typescript/src/components/input-group/index.d.ts +34 -0
  71. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/label/index.d.ts +28 -0
  73. package/lib/typescript/src/components/label/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/progress/index.d.ts +132 -0
  75. package/lib/typescript/src/components/progress/index.d.ts.map +1 -0
  76. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/spinner/index.d.ts +2 -2
  78. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
  79. package/lib/typescript/src/components/steps/index.d.ts +62 -0
  80. package/lib/typescript/src/components/steps/index.d.ts.map +1 -0
  81. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/toast/index.d.ts +176 -0
  83. package/lib/typescript/src/components/toast/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/toast/toast-store.d.ts +65 -0
  85. package/lib/typescript/src/components/toast/toast-store.d.ts.map +1 -0
  86. package/lib/typescript/src/components/typography/index.d.ts +77 -0
  87. package/lib/typescript/src/components/typography/index.d.ts.map +1 -0
  88. package/lib/typescript/src/icons/index.d.ts +25 -0
  89. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +12 -3
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/providers/panel-ui-provider.d.ts +20 -5
  93. package/lib/typescript/src/providers/panel-ui-provider.d.ts.map +1 -1
  94. package/lib/typescript/src/theme/use-theme.d.ts +64 -4
  95. package/lib/typescript/src/theme/use-theme.d.ts.map +1 -1
  96. package/package.json +28 -5
  97. package/src/components/alert/index.tsx +104 -28
  98. package/src/components/avatar/index.tsx +1 -1
  99. package/src/components/badge/index.tsx +3 -3
  100. package/src/components/bottom-sheet/index.tsx +8 -3
  101. package/src/components/button/index.tsx +47 -7
  102. package/src/components/card/index.tsx +5 -5
  103. package/src/components/checkbox/index.tsx +18 -4
  104. package/src/components/dialog/index.tsx +10 -5
  105. package/src/components/empty-state/index.tsx +136 -0
  106. package/src/components/frame/index.tsx +104 -0
  107. package/src/components/inline-select/index.tsx +185 -0
  108. package/src/components/input/index.tsx +1 -1
  109. package/src/components/input-group/index.tsx +164 -0
  110. package/src/components/label/index.tsx +128 -0
  111. package/src/components/progress/index.tsx +147 -0
  112. package/src/components/select/index.tsx +30 -23
  113. package/src/components/skeleton/index.tsx +1 -1
  114. package/src/components/spinner/index.tsx +1 -1
  115. package/src/components/steps/index.tsx +343 -0
  116. package/src/components/tabs/index.tsx +10 -2
  117. package/src/components/toast/index.tsx +447 -0
  118. package/src/components/toast/toast-store.ts +120 -0
  119. package/src/components/typography/index.tsx +121 -0
  120. package/src/icons/index.tsx +152 -1
  121. package/src/index.ts +82 -3
  122. package/src/providers/panel-ui-provider.tsx +35 -10
  123. package/src/theme/use-theme.ts +119 -4
  124. 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
- className={slots.row({ className })}
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 ? <Text className={slots.label()}>{label}</Text> : null}
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 { open, setOpen } = useDialog('Dialog.Content');
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
- <Animated.View
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
- {children}
123
+ >
124
+ {children}
125
+ </Animated.View>
121
126
  </Animated.View>
122
- </Animated.View>
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
+ });