mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {
3 AccessibilityProps,
4 Pressable,
5 PressableProps,
6 StyleProp,
7 StyleSheet,
8 Text,
9 TextProps,
10 TextStyle,
11 View,
12 ViewStyle,
13} from 'react-native'
14import {LinearGradient} from 'expo-linear-gradient'
15
16import {android, atoms as a, flatten, tokens, useTheme} from '#/alf'
17import {Props as SVGIconProps} from '#/components/icons/common'
18import {normalizeTextStyles} from '#/components/Typography'
19
20export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
21export type ButtonColor =
22 | 'primary'
23 | 'secondary'
24 | 'negative'
25 | 'gradient_sky'
26 | 'gradient_midnight'
27 | 'gradient_sunrise'
28 | 'gradient_sunset'
29 | 'gradient_nordic'
30 | 'gradient_bonfire'
31export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | 'large'
32export type ButtonShape = 'round' | 'square' | 'default'
33export type VariantProps = {
34 /**
35 * The style variation of the button
36 */
37 variant?: ButtonVariant
38 /**
39 * The color of the button
40 */
41 color?: ButtonColor
42 /**
43 * The size of the button
44 */
45 size?: ButtonSize
46 /**
47 * The shape of the button
48 */
49 shape?: ButtonShape
50}
51
52export type ButtonState = {
53 hovered: boolean
54 focused: boolean
55 pressed: boolean
56 disabled: boolean
57}
58
59export type ButtonContext = VariantProps & ButtonState
60
61type NonTextElements =
62 | React.ReactElement
63 | Iterable<React.ReactElement | null | undefined | boolean>
64
65export type ButtonProps = Pick<
66 PressableProps,
67 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
68> &
69 AccessibilityProps &
70 VariantProps & {
71 testID?: string
72 label: string
73 style?: StyleProp<ViewStyle>
74 hoverStyle?: StyleProp<ViewStyle>
75 children: NonTextElements | ((context: ButtonContext) => NonTextElements)
76 }
77
78export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
79
80const Context = React.createContext<VariantProps & ButtonState>({
81 hovered: false,
82 focused: false,
83 pressed: false,
84 disabled: false,
85})
86
87export function useButtonContext() {
88 return React.useContext(Context)
89}
90
91export function Button({
92 children,
93 variant,
94 color,
95 size,
96 shape = 'default',
97 label,
98 disabled = false,
99 style,
100 hoverStyle: hoverStyleProp,
101 ...rest
102}: ButtonProps) {
103 const t = useTheme()
104 const [state, setState] = React.useState({
105 pressed: false,
106 hovered: false,
107 focused: false,
108 })
109
110 const onPressIn = React.useCallback(() => {
111 setState(s => ({
112 ...s,
113 pressed: true,
114 }))
115 }, [setState])
116 const onPressOut = React.useCallback(() => {
117 setState(s => ({
118 ...s,
119 pressed: false,
120 }))
121 }, [setState])
122 const onHoverIn = React.useCallback(() => {
123 setState(s => ({
124 ...s,
125 hovered: true,
126 }))
127 }, [setState])
128 const onHoverOut = React.useCallback(() => {
129 setState(s => ({
130 ...s,
131 hovered: false,
132 }))
133 }, [setState])
134 const onFocus = React.useCallback(() => {
135 setState(s => ({
136 ...s,
137 focused: true,
138 }))
139 }, [setState])
140 const onBlur = React.useCallback(() => {
141 setState(s => ({
142 ...s,
143 focused: false,
144 }))
145 }, [setState])
146
147 const {baseStyles, hoverStyles} = React.useMemo(() => {
148 const baseStyles: ViewStyle[] = []
149 const hoverStyles: ViewStyle[] = []
150 const light = t.name === 'light'
151
152 if (color === 'primary') {
153 if (variant === 'solid') {
154 if (!disabled) {
155 baseStyles.push({
156 backgroundColor: t.palette.primary_500,
157 })
158 hoverStyles.push({
159 backgroundColor: t.palette.primary_600,
160 })
161 } else {
162 baseStyles.push({
163 backgroundColor: t.palette.primary_700,
164 })
165 }
166 } else if (variant === 'outline') {
167 baseStyles.push(a.border, t.atoms.bg, {
168 borderWidth: 1,
169 })
170
171 if (!disabled) {
172 baseStyles.push(a.border, {
173 borderColor: t.palette.primary_500,
174 })
175 hoverStyles.push(a.border, {
176 backgroundColor: light
177 ? t.palette.primary_50
178 : t.palette.primary_950,
179 })
180 } else {
181 baseStyles.push(a.border, {
182 borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
183 })
184 }
185 } else if (variant === 'ghost') {
186 if (!disabled) {
187 baseStyles.push(t.atoms.bg)
188 hoverStyles.push({
189 backgroundColor: light
190 ? t.palette.primary_100
191 : t.palette.primary_900,
192 })
193 }
194 }
195 } else if (color === 'secondary') {
196 if (variant === 'solid') {
197 if (!disabled) {
198 baseStyles.push({
199 backgroundColor: t.palette.contrast_25,
200 })
201 hoverStyles.push({
202 backgroundColor: t.palette.contrast_50,
203 })
204 } else {
205 baseStyles.push({
206 backgroundColor: t.palette.contrast_100,
207 })
208 }
209 } else if (variant === 'outline') {
210 baseStyles.push(a.border, t.atoms.bg, {
211 borderWidth: 1,
212 })
213
214 if (!disabled) {
215 baseStyles.push(a.border, {
216 borderColor: t.palette.contrast_300,
217 })
218 hoverStyles.push(t.atoms.bg_contrast_50)
219 } else {
220 baseStyles.push(a.border, {
221 borderColor: t.palette.contrast_200,
222 })
223 }
224 } else if (variant === 'ghost') {
225 if (!disabled) {
226 baseStyles.push(t.atoms.bg)
227 hoverStyles.push({
228 backgroundColor: t.palette.contrast_100,
229 })
230 }
231 }
232 } else if (color === 'negative') {
233 if (variant === 'solid') {
234 if (!disabled) {
235 baseStyles.push({
236 backgroundColor: t.palette.negative_500,
237 })
238 hoverStyles.push({
239 backgroundColor: t.palette.negative_600,
240 })
241 } else {
242 baseStyles.push({
243 backgroundColor: t.palette.negative_700,
244 })
245 }
246 } else if (variant === 'outline') {
247 baseStyles.push(a.border, t.atoms.bg, {
248 borderWidth: 1,
249 })
250
251 if (!disabled) {
252 baseStyles.push(a.border, {
253 borderColor: t.palette.negative_500,
254 })
255 hoverStyles.push(a.border, {
256 backgroundColor: light
257 ? t.palette.negative_50
258 : t.palette.negative_975,
259 })
260 } else {
261 baseStyles.push(a.border, {
262 borderColor: light
263 ? t.palette.negative_200
264 : t.palette.negative_900,
265 })
266 }
267 } else if (variant === 'ghost') {
268 if (!disabled) {
269 baseStyles.push(t.atoms.bg)
270 hoverStyles.push({
271 backgroundColor: light
272 ? t.palette.negative_100
273 : t.palette.negative_975,
274 })
275 }
276 }
277 }
278
279 if (shape === 'default') {
280 if (size === 'large') {
281 baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
282 } else if (size === 'medium') {
283 baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
284 } else if (size === 'small') {
285 baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
286 } else if (size === 'xsmall') {
287 baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
288 } else if (size === 'tiny') {
289 baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
290 }
291 } else if (shape === 'round' || shape === 'square') {
292 if (size === 'large') {
293 if (shape === 'round') {
294 baseStyles.push({height: 54, width: 54})
295 } else {
296 baseStyles.push({height: 50, width: 50})
297 }
298 } else if (size === 'small') {
299 baseStyles.push({height: 34, width: 34})
300 } else if (size === 'xsmall') {
301 baseStyles.push({height: 28, width: 28})
302 } else if (size === 'tiny') {
303 baseStyles.push({height: 20, width: 20})
304 }
305
306 if (shape === 'round') {
307 baseStyles.push(a.rounded_full)
308 } else if (shape === 'square') {
309 if (size === 'tiny') {
310 baseStyles.push(a.rounded_xs)
311 } else {
312 baseStyles.push(a.rounded_sm)
313 }
314 }
315 }
316
317 return {
318 baseStyles,
319 hoverStyles,
320 }
321 }, [t, variant, color, size, shape, disabled])
322
323 const {gradientColors, gradientHoverColors, gradientLocations} =
324 React.useMemo(() => {
325 const colors: string[] = []
326 const hoverColors: string[] = []
327 const locations: number[] = []
328 const gradient = {
329 primary: tokens.gradients.sky,
330 secondary: tokens.gradients.sky,
331 negative: tokens.gradients.sky,
332 gradient_sky: tokens.gradients.sky,
333 gradient_midnight: tokens.gradients.midnight,
334 gradient_sunrise: tokens.gradients.sunrise,
335 gradient_sunset: tokens.gradients.sunset,
336 gradient_nordic: tokens.gradients.nordic,
337 gradient_bonfire: tokens.gradients.bonfire,
338 }[color || 'primary']
339
340 if (variant === 'gradient') {
341 colors.push(...gradient.values.map(([_, color]) => color))
342 hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
343 locations.push(...gradient.values.map(([location, _]) => location))
344 }
345
346 return {
347 gradientColors: colors,
348 gradientHoverColors: hoverColors,
349 gradientLocations: locations,
350 }
351 }, [variant, color])
352
353 const context = React.useMemo<ButtonContext>(
354 () => ({
355 ...state,
356 variant,
357 color,
358 size,
359 disabled: disabled || false,
360 }),
361 [state, variant, color, size, disabled],
362 )
363
364 const flattenedBaseStyles = flatten(baseStyles)
365
366 return (
367 <Pressable
368 role="button"
369 accessibilityHint={undefined} // optional
370 {...rest}
371 aria-label={label}
372 aria-pressed={state.pressed}
373 accessibilityLabel={label}
374 disabled={disabled || false}
375 accessibilityState={{
376 disabled: disabled || false,
377 }}
378 style={[
379 a.flex_row,
380 a.align_center,
381 a.justify_center,
382 flattenedBaseStyles,
383 flatten(style),
384 ...(state.hovered || state.pressed
385 ? [hoverStyles, flatten(hoverStyleProp)]
386 : []),
387 ]}
388 onPressIn={onPressIn}
389 onPressOut={onPressOut}
390 onHoverIn={onHoverIn}
391 onHoverOut={onHoverOut}
392 onFocus={onFocus}
393 onBlur={onBlur}>
394 {variant === 'gradient' && (
395 <View
396 style={[
397 a.absolute,
398 a.inset_0,
399 a.overflow_hidden,
400 {borderRadius: flattenedBaseStyles.borderRadius},
401 ]}>
402 <LinearGradient
403 colors={
404 state.hovered || state.pressed
405 ? gradientHoverColors
406 : gradientColors
407 }
408 locations={gradientLocations}
409 start={{x: 0, y: 0}}
410 end={{x: 1, y: 1}}
411 style={[a.absolute, a.inset_0]}
412 />
413 </View>
414 )}
415 <Context.Provider value={context}>
416 {typeof children === 'function' ? children(context) : children}
417 </Context.Provider>
418 </Pressable>
419 )
420}
421
422export function useSharedButtonTextStyles() {
423 const t = useTheme()
424 const {color, variant, disabled, size} = useButtonContext()
425 return React.useMemo(() => {
426 const baseStyles: TextStyle[] = []
427 const light = t.name === 'light'
428
429 if (color === 'primary') {
430 if (variant === 'solid') {
431 if (!disabled) {
432 baseStyles.push({color: t.palette.white})
433 } else {
434 baseStyles.push({color: t.palette.white, opacity: 0.5})
435 }
436 } else if (variant === 'outline') {
437 if (!disabled) {
438 baseStyles.push({
439 color: light ? t.palette.primary_600 : t.palette.primary_500,
440 })
441 } else {
442 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
443 }
444 } else if (variant === 'ghost') {
445 if (!disabled) {
446 baseStyles.push({color: t.palette.primary_600})
447 } else {
448 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
449 }
450 }
451 } else if (color === 'secondary') {
452 if (variant === 'solid' || variant === 'gradient') {
453 if (!disabled) {
454 baseStyles.push({
455 color: t.palette.contrast_700,
456 })
457 } else {
458 baseStyles.push({
459 color: t.palette.contrast_400,
460 })
461 }
462 } else if (variant === 'outline') {
463 if (!disabled) {
464 baseStyles.push({
465 color: t.palette.contrast_600,
466 })
467 } else {
468 baseStyles.push({
469 color: t.palette.contrast_300,
470 })
471 }
472 } else if (variant === 'ghost') {
473 if (!disabled) {
474 baseStyles.push({
475 color: t.palette.contrast_600,
476 })
477 } else {
478 baseStyles.push({
479 color: t.palette.contrast_300,
480 })
481 }
482 }
483 } else if (color === 'negative') {
484 if (variant === 'solid' || variant === 'gradient') {
485 if (!disabled) {
486 baseStyles.push({color: t.palette.white})
487 } else {
488 baseStyles.push({color: t.palette.white, opacity: 0.5})
489 }
490 } else if (variant === 'outline') {
491 if (!disabled) {
492 baseStyles.push({color: t.palette.negative_400})
493 } else {
494 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
495 }
496 } else if (variant === 'ghost') {
497 if (!disabled) {
498 baseStyles.push({color: t.palette.negative_400})
499 } else {
500 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
501 }
502 }
503 } else {
504 if (!disabled) {
505 baseStyles.push({color: t.palette.white})
506 } else {
507 baseStyles.push({color: t.palette.white, opacity: 0.5})
508 }
509 }
510
511 if (size === 'large') {
512 baseStyles.push(a.text_md, android({paddingBottom: 1}))
513 } else if (size === 'tiny') {
514 baseStyles.push(a.text_xs, android({paddingBottom: 1}))
515 } else {
516 baseStyles.push(a.text_sm, android({paddingBottom: 1}))
517 }
518
519 return StyleSheet.flatten(baseStyles)
520 }, [t, variant, color, size, disabled])
521}
522
523export function ButtonText({children, style, ...rest}: ButtonTextProps) {
524 const textStyles = useSharedButtonTextStyles()
525
526 return (
527 <Text
528 {...rest}
529 style={normalizeTextStyles([
530 a.font_bold,
531 a.text_center,
532 textStyles,
533 style,
534 ])}>
535 {children}
536 </Text>
537 )
538}
539
540export function ButtonIcon({
541 icon: Comp,
542 position,
543 size: iconSize,
544}: {
545 icon: React.ComponentType<SVGIconProps>
546 position?: 'left' | 'right'
547 size?: SVGIconProps['size']
548}) {
549 const {size, disabled} = useButtonContext()
550 const textStyles = useSharedButtonTextStyles()
551
552 return (
553 <View
554 style={[
555 a.z_20,
556 {
557 opacity: disabled ? 0.7 : 1,
558 marginLeft: position === 'left' ? -2 : 0,
559 marginRight: position === 'right' ? -2 : 0,
560 },
561 ]}>
562 <Comp
563 size={
564 iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
565 }
566 style={[{color: textStyles.color, pointerEvents: 'none'}]}
567 />
568 </View>
569 )
570}