forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {createContext, useContext, useMemo} from 'react'
2import {
3 type GestureResponderEvent,
4 type StyleProp,
5 View,
6 type ViewStyle,
7} from 'react-native'
8
9import {HITSLOP_10} from '#/lib/constants'
10import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
11import * as Button from '#/components/Button'
12import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
13import {Link, type LinkProps} from '#/components/Link'
14import {createPortalGroup} from '#/components/Portal'
15import {Text} from '#/components/Typography'
16
17const ItemContext = createContext({
18 destructive: false,
19 withinGroup: false,
20})
21ItemContext.displayName = 'SettingsListItemContext'
22
23const Portal = createPortalGroup()
24
25export function Container({children}: {children: React.ReactNode}) {
26 return <View style={[a.flex_1, a.py_md]}>{children}</View>
27}
28
29/**
30 * This uses `Portal` magic ✨ to render the icons and title correctly. ItemIcon and ItemText components
31 * get teleported to the top row, leaving the rest of the children in the bottom row.
32 */
33export function Group({
34 children,
35 destructive = false,
36 iconInset = true,
37 style,
38 contentContainerStyle,
39}: {
40 children: React.ReactNode
41 destructive?: boolean
42 iconInset?: boolean
43 style?: StyleProp<ViewStyle>
44 contentContainerStyle?: StyleProp<ViewStyle>
45}) {
46 const context = useMemo(
47 () => ({destructive, withinGroup: true}),
48 [destructive],
49 )
50 return (
51 <View style={[a.w_full, style]}>
52 <Portal.Provider>
53 <ItemContext.Provider value={context}>
54 <Item style={[a.pb_2xs, {minHeight: 42}]}>
55 <Portal.Outlet />
56 </Item>
57 <Item
58 style={[
59 a.flex_col,
60 a.pt_2xs,
61 a.align_start,
62 a.gap_0,
63 contentContainerStyle,
64 ]}
65 iconInset={iconInset}>
66 {children}
67 </Item>
68 </ItemContext.Provider>
69 </Portal.Provider>
70 </View>
71 )
72}
73
74export function Item({
75 children,
76 destructive,
77 iconInset = false,
78 style,
79}: {
80 children?: React.ReactNode
81 destructive?: boolean
82 /**
83 * Adds left padding so that the content will be aligned with other Items that contain icons
84 * @default false
85 */
86 iconInset?: boolean
87 style?: StyleProp<ViewStyle>
88}) {
89 const context = useContext(ItemContext)
90 const childContext = useMemo(() => {
91 if (typeof destructive !== 'boolean') return context
92 return {...context, destructive}
93 }, [context, destructive])
94 return (
95 <View
96 style={[
97 a.px_xl,
98 a.py_sm,
99 a.align_center,
100 a.gap_sm,
101 a.w_full,
102 a.flex_row,
103 {minHeight: 48},
104 iconInset && {
105 paddingLeft:
106 // existing padding
107 a.pl_xl.paddingLeft +
108 // icon
109 24 +
110 // gap
111 a.gap_sm.gap,
112 },
113 style,
114 ]}>
115 <ItemContext.Provider value={childContext}>
116 {children}
117 </ItemContext.Provider>
118 </View>
119 )
120}
121
122export function LinkItem({
123 children,
124 destructive = false,
125 contentContainerStyle,
126 chevronColor,
127 ...props
128}: Omit<LinkProps, Button.UninheritableButtonProps> & {
129 contentContainerStyle?: StyleProp<ViewStyle>
130 destructive?: boolean
131 chevronColor?: string
132}) {
133 const t = useTheme()
134
135 return (
136 <Link {...props}>
137 {args => (
138 <Item
139 destructive={destructive}
140 style={[
141 (args.hovered || args.pressed) && [t.atoms.bg_contrast_25],
142 contentContainerStyle,
143 ]}>
144 {typeof children === 'function' ? children(args) : children}
145 <Chevron color={chevronColor} />
146 </Item>
147 )}
148 </Link>
149 )
150}
151
152export function PressableItem({
153 children,
154 destructive = false,
155 contentContainerStyle,
156 hoverStyle,
157 ...props
158}: Omit<Button.ButtonProps, Button.UninheritableButtonProps> & {
159 contentContainerStyle?: StyleProp<ViewStyle>
160 destructive?: boolean
161}) {
162 const t = useTheme()
163 return (
164 <Button.Button {...props}>
165 {args => (
166 <Item
167 destructive={destructive}
168 style={[
169 (args.hovered || args.pressed) && [
170 t.atoms.bg_contrast_25,
171 hoverStyle,
172 ],
173 contentContainerStyle,
174 ]}>
175 {typeof children === 'function' ? children(args) : children}
176 </Item>
177 )}
178 </Button.Button>
179 )
180}
181
182export function ItemIcon({
183 icon: Comp,
184 size = 'lg',
185 color: colorProp,
186}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
187 color?: string
188}) {
189 const t = useTheme()
190 const {destructive, withinGroup} = useContext(ItemContext)
191
192 /*
193 * Copied here from icons/common.tsx so we can tweak if we need to, but
194 * also so that we can calculate transforms.
195 */
196 const iconSize = {
197 '2xs': 8,
198 xs: 12,
199 sm: 16,
200 md: 20,
201 lg: 24,
202 xl: 28,
203 '2xl': 32,
204 '3xl': 40,
205 }[size]
206
207 const color =
208 colorProp ?? (destructive ? t.palette.negative_500 : t.atoms.text.color)
209
210 const content = (
211 <View style={[a.z_20, {width: iconSize, height: iconSize}]}>
212 <Comp width={iconSize} style={[{color}]} />
213 </View>
214 )
215
216 if (withinGroup) {
217 return <Portal.Portal>{content}</Portal.Portal>
218 } else {
219 return content
220 }
221}
222
223export function ItemText({
224 style,
225 ...props
226}: React.ComponentProps<typeof Button.ButtonText>) {
227 const t = useTheme()
228 const {destructive, withinGroup} = useContext(ItemContext)
229
230 const content = (
231 <Button.ButtonText
232 style={[
233 a.text_md,
234 a.font_normal,
235 a.text_left,
236 a.flex_1,
237 destructive ? {color: t.palette.negative_500} : t.atoms.text,
238 style,
239 ]}
240 {...props}
241 />
242 )
243
244 if (withinGroup) {
245 return <Portal.Portal>{content}</Portal.Portal>
246 } else {
247 return content
248 }
249}
250
251export function Divider({style}: ViewStyleProp) {
252 const t = useTheme()
253 return (
254 <View
255 style={[
256 a.border_t,
257 t.atoms.border_contrast_low,
258 a.w_full,
259 a.my_sm,
260 style,
261 ]}
262 />
263 )
264}
265
266export function Chevron({color: colorProp}: {color?: string}) {
267 const {destructive} = useContext(ItemContext)
268 const t = useTheme()
269 const color =
270 colorProp ?? (destructive ? t.palette.negative_500 : t.palette.contrast_500)
271 return <ItemIcon icon={ChevronRightIcon} size="md" color={color} />
272}
273
274export function BadgeText({
275 children,
276 style,
277}: {
278 children: React.ReactNode
279 style?: StyleProp<ViewStyle>
280}) {
281 const t = useTheme()
282 return (
283 <Text
284 style={[
285 t.atoms.text_contrast_low,
286 a.text_md,
287 a.text_right,
288 a.leading_snug,
289 style,
290 ]}
291 numberOfLines={1}>
292 {children}
293 </Text>
294 )
295}
296
297export function BadgeButton({
298 label,
299 onPress,
300}: {
301 label: string
302 onPress: (evt: GestureResponderEvent) => void
303}) {
304 const t = useTheme()
305 return (
306 <Button.Button label={label} onPress={onPress} hitSlop={HITSLOP_10}>
307 {({pressed}) => (
308 <Button.ButtonText
309 style={[
310 a.text_md,
311 a.font_normal,
312 a.text_right,
313 {color: pressed ? t.palette.contrast_300 : t.palette.primary_500},
314 ]}>
315 {label}
316 </Button.ButtonText>
317 )}
318 </Button.Button>
319 )
320}