mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {
3 AccessibilityProps,
4 GestureResponderEvent,
5 MouseEvent,
6 Pressable,
7 PressableProps,
8 StyleProp,
9 StyleSheet,
10 TextProps,
11 TextStyle,
12 View,
13 ViewStyle,
14} from 'react-native'
15import {LinearGradient} from 'expo-linear-gradient'
16
17import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
18import {Props as SVGIconProps} from '#/components/icons/common'
19import {Text} from '#/components/Typography'
20
21export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
22export type ButtonColor =
23 | 'primary'
24 | 'secondary'
25 | 'secondary_inverted'
26 | 'negative'
27 | 'gradient_primary'
28 | 'gradient_sky'
29 | 'gradient_midnight'
30 | 'gradient_sunrise'
31 | 'gradient_sunset'
32 | 'gradient_nordic'
33 | 'gradient_bonfire'
34export type ButtonSize = 'tiny' | 'small' | 'large'
35export type ButtonShape = 'round' | 'square' | 'default'
36export type VariantProps = {
37 /**
38 * The style variation of the button
39 */
40 variant?: ButtonVariant
41 /**
42 * The color of the button
43 */
44 color?: ButtonColor
45 /**
46 * The size of the button
47 */
48 size?: ButtonSize
49 /**
50 * The shape of the button
51 */
52 shape?: ButtonShape
53}
54
55export type ButtonState = {
56 hovered: boolean
57 focused: boolean
58 pressed: boolean
59 disabled: boolean
60}
61
62export type ButtonContext = VariantProps & ButtonState
63
64type NonTextElements =
65 | React.ReactElement
66 | Iterable<React.ReactElement | null | undefined | boolean>
67
68export type ButtonProps = Pick<
69 PressableProps,
70 | 'disabled'
71 | 'onPress'
72 | 'testID'
73 | 'onLongPress'
74 | 'hitSlop'
75 | 'onHoverIn'
76 | 'onHoverOut'
77 | 'onPressIn'
78 | 'onPressOut'
79> &
80 AccessibilityProps &
81 VariantProps & {
82 testID?: string
83 /**
84 * For a11y, try to make this descriptive and clear
85 */
86 label: string
87 style?: StyleProp<ViewStyle>
88 hoverStyle?: StyleProp<ViewStyle>
89 children: NonTextElements | ((context: ButtonContext) => NonTextElements)
90 PressableComponent?: React.ComponentType<PressableProps>
91 }
92
93export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
94
95const Context = React.createContext<VariantProps & ButtonState>({
96 hovered: false,
97 focused: false,
98 pressed: false,
99 disabled: false,
100})
101
102export function useButtonContext() {
103 return React.useContext(Context)
104}
105
106export const Button = React.forwardRef<View, ButtonProps>(
107 (
108 {
109 children,
110 variant,
111 color,
112 size,
113 shape = 'default',
114 label,
115 disabled = false,
116 style,
117 hoverStyle: hoverStyleProp,
118 PressableComponent = Pressable,
119 ...rest
120 },
121 ref,
122 ) => {
123 const t = useTheme()
124 const [state, setState] = React.useState({
125 pressed: false,
126 hovered: false,
127 focused: false,
128 })
129
130 const onPressInOuter = rest.onPressIn
131 const onPressIn = React.useCallback(
132 (e: GestureResponderEvent) => {
133 setState(s => ({
134 ...s,
135 pressed: true,
136 }))
137 onPressInOuter?.(e)
138 },
139 [setState, onPressInOuter],
140 )
141 const onPressOutOuter = rest.onPressOut
142 const onPressOut = React.useCallback(
143 (e: GestureResponderEvent) => {
144 setState(s => ({
145 ...s,
146 pressed: false,
147 }))
148 onPressOutOuter?.(e)
149 },
150 [setState, onPressOutOuter],
151 )
152 const onHoverInOuter = rest.onHoverIn
153 const onHoverIn = React.useCallback(
154 (e: MouseEvent) => {
155 setState(s => ({
156 ...s,
157 hovered: true,
158 }))
159 onHoverInOuter?.(e)
160 },
161 [setState, onHoverInOuter],
162 )
163 const onHoverOutOuter = rest.onHoverOut
164 const onHoverOut = React.useCallback(
165 (e: MouseEvent) => {
166 setState(s => ({
167 ...s,
168 hovered: false,
169 }))
170 onHoverOutOuter?.(e)
171 },
172 [setState, onHoverOutOuter],
173 )
174 const onFocus = React.useCallback(() => {
175 setState(s => ({
176 ...s,
177 focused: true,
178 }))
179 }, [setState])
180 const onBlur = React.useCallback(() => {
181 setState(s => ({
182 ...s,
183 focused: false,
184 }))
185 }, [setState])
186
187 const {baseStyles, hoverStyles} = React.useMemo(() => {
188 const baseStyles: ViewStyle[] = []
189 const hoverStyles: ViewStyle[] = []
190
191 if (color === 'primary') {
192 if (variant === 'solid') {
193 if (!disabled) {
194 baseStyles.push({
195 backgroundColor: t.palette.primary_500,
196 })
197 hoverStyles.push({
198 backgroundColor: t.palette.primary_600,
199 })
200 } else {
201 baseStyles.push({
202 backgroundColor: select(t.name, {
203 light: t.palette.primary_700,
204 dim: t.palette.primary_300,
205 dark: t.palette.primary_300,
206 }),
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.primary_500,
217 })
218 hoverStyles.push(a.border, {
219 backgroundColor: t.palette.primary_50,
220 })
221 } else {
222 baseStyles.push(a.border, {
223 borderColor: t.palette.primary_200,
224 })
225 }
226 } else if (variant === 'ghost') {
227 if (!disabled) {
228 baseStyles.push(t.atoms.bg)
229 hoverStyles.push({
230 backgroundColor: t.palette.primary_100,
231 })
232 }
233 }
234 } else if (color === 'secondary') {
235 if (variant === 'solid') {
236 if (!disabled) {
237 baseStyles.push(t.atoms.bg_contrast_25)
238 hoverStyles.push(t.atoms.bg_contrast_50)
239 } else {
240 baseStyles.push(t.atoms.bg_contrast_100)
241 }
242 } else if (variant === 'outline') {
243 baseStyles.push(a.border, t.atoms.bg, {
244 borderWidth: 1,
245 })
246
247 if (!disabled) {
248 baseStyles.push(a.border, {
249 borderColor: t.palette.contrast_300,
250 })
251 hoverStyles.push(t.atoms.bg_contrast_50)
252 } else {
253 baseStyles.push(a.border, {
254 borderColor: t.palette.contrast_200,
255 })
256 }
257 } else if (variant === 'ghost') {
258 if (!disabled) {
259 baseStyles.push(t.atoms.bg)
260 hoverStyles.push({
261 backgroundColor: t.palette.contrast_25,
262 })
263 }
264 }
265 } else if (color === 'secondary_inverted') {
266 if (variant === 'solid') {
267 if (!disabled) {
268 baseStyles.push({
269 backgroundColor: t.palette.contrast_900,
270 })
271 hoverStyles.push({
272 backgroundColor: t.palette.contrast_950,
273 })
274 } else {
275 baseStyles.push({
276 backgroundColor: t.palette.contrast_600,
277 })
278 }
279 } else if (variant === 'outline') {
280 baseStyles.push(a.border, t.atoms.bg, {
281 borderWidth: 1,
282 })
283
284 if (!disabled) {
285 baseStyles.push(a.border, {
286 borderColor: t.palette.contrast_300,
287 })
288 hoverStyles.push(t.atoms.bg_contrast_50)
289 } else {
290 baseStyles.push(a.border, {
291 borderColor: t.palette.contrast_200,
292 })
293 }
294 } else if (variant === 'ghost') {
295 if (!disabled) {
296 baseStyles.push(t.atoms.bg)
297 hoverStyles.push({
298 backgroundColor: t.palette.contrast_25,
299 })
300 }
301 }
302 } else if (color === 'negative') {
303 if (variant === 'solid') {
304 if (!disabled) {
305 baseStyles.push({
306 backgroundColor: t.palette.negative_500,
307 })
308 hoverStyles.push({
309 backgroundColor: t.palette.negative_600,
310 })
311 } else {
312 baseStyles.push({
313 backgroundColor: select(t.name, {
314 light: t.palette.negative_700,
315 dim: t.palette.negative_300,
316 dark: t.palette.negative_300,
317 }),
318 })
319 }
320 } else if (variant === 'outline') {
321 baseStyles.push(a.border, t.atoms.bg, {
322 borderWidth: 1,
323 })
324
325 if (!disabled) {
326 baseStyles.push(a.border, {
327 borderColor: t.palette.negative_500,
328 })
329 hoverStyles.push(a.border, {
330 backgroundColor: t.palette.negative_50,
331 })
332 } else {
333 baseStyles.push(a.border, {
334 borderColor: t.palette.negative_200,
335 })
336 }
337 } else if (variant === 'ghost') {
338 if (!disabled) {
339 baseStyles.push(t.atoms.bg)
340 hoverStyles.push({
341 backgroundColor: t.palette.negative_100,
342 })
343 }
344 }
345 }
346
347 if (shape === 'default') {
348 if (size === 'large') {
349 baseStyles.push({
350 paddingVertical: 13,
351 paddingHorizontal: 20,
352 borderRadius: 8,
353 gap: 8,
354 })
355 } else if (size === 'small') {
356 baseStyles.push({
357 paddingVertical: 9,
358 paddingHorizontal: 12,
359 borderRadius: 6,
360 gap: 6,
361 })
362 } else if (size === 'tiny') {
363 baseStyles.push({
364 paddingVertical: 4,
365 paddingHorizontal: 8,
366 borderRadius: 4,
367 gap: 4,
368 })
369 }
370 } else if (shape === 'round' || shape === 'square') {
371 if (size === 'large') {
372 if (shape === 'round') {
373 baseStyles.push({height: 46, width: 46})
374 } else {
375 baseStyles.push({height: 44, width: 44})
376 }
377 } else if (size === 'small') {
378 if (shape === 'round') {
379 baseStyles.push({height: 34, width: 34})
380 } else {
381 baseStyles.push({height: 34, width: 34})
382 }
383 } else if (size === 'tiny') {
384 if (shape === 'round') {
385 baseStyles.push({height: 22, width: 22})
386 } else {
387 baseStyles.push({height: 21, width: 21})
388 }
389 }
390
391 if (shape === 'round') {
392 baseStyles.push(a.rounded_full)
393 } else if (shape === 'square') {
394 if (size === 'tiny') {
395 baseStyles.push(a.rounded_xs)
396 } else {
397 baseStyles.push(a.rounded_sm)
398 }
399 }
400 }
401
402 return {
403 baseStyles,
404 hoverStyles,
405 }
406 }, [t, variant, color, size, shape, disabled])
407
408 const {gradientColors, gradientHoverColors, gradientLocations} =
409 React.useMemo(() => {
410 const colors: string[] = []
411 const hoverColors: string[] = []
412 const locations: number[] = []
413 const gradient = {
414 primary: tokens.gradients.sky,
415 secondary: tokens.gradients.sky,
416 secondary_inverted: tokens.gradients.sky,
417 negative: tokens.gradients.sky,
418 gradient_primary: tokens.gradients.primary,
419 gradient_sky: tokens.gradients.sky,
420 gradient_midnight: tokens.gradients.midnight,
421 gradient_sunrise: tokens.gradients.sunrise,
422 gradient_sunset: tokens.gradients.sunset,
423 gradient_nordic: tokens.gradients.nordic,
424 gradient_bonfire: tokens.gradients.bonfire,
425 }[color || 'primary']
426
427 if (variant === 'gradient') {
428 colors.push(...gradient.values.map(([_, color]) => color))
429 hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
430 locations.push(...gradient.values.map(([location, _]) => location))
431 }
432
433 return {
434 gradientColors: colors,
435 gradientHoverColors: hoverColors,
436 gradientLocations: locations,
437 }
438 }, [variant, color])
439
440 const context = React.useMemo<ButtonContext>(
441 () => ({
442 ...state,
443 variant,
444 color,
445 size,
446 disabled: disabled || false,
447 }),
448 [state, variant, color, size, disabled],
449 )
450
451 const flattenedBaseStyles = flatten([baseStyles, style])
452
453 return (
454 <PressableComponent
455 role="button"
456 accessibilityHint={undefined} // optional
457 {...rest}
458 // @ts-ignore - this will always be a pressable
459 ref={ref}
460 aria-label={label}
461 accessibilityLabel={label}
462 disabled={disabled || false}
463 accessibilityState={{
464 disabled: disabled || false,
465 }}
466 style={[
467 a.flex_row,
468 a.align_center,
469 a.justify_center,
470 flattenedBaseStyles,
471 ...(state.hovered || state.pressed
472 ? [hoverStyles, flatten(hoverStyleProp)]
473 : []),
474 ]}
475 onPressIn={onPressIn}
476 onPressOut={onPressOut}
477 onHoverIn={onHoverIn}
478 onHoverOut={onHoverOut}
479 onFocus={onFocus}
480 onBlur={onBlur}>
481 {variant === 'gradient' && (
482 <View
483 style={[
484 a.absolute,
485 a.inset_0,
486 a.overflow_hidden,
487 {borderRadius: flattenedBaseStyles.borderRadius},
488 ]}>
489 <LinearGradient
490 colors={
491 state.hovered || state.pressed
492 ? gradientHoverColors
493 : gradientColors
494 }
495 locations={gradientLocations}
496 start={{x: 0, y: 0}}
497 end={{x: 1, y: 1}}
498 style={[a.absolute, a.inset_0]}
499 />
500 </View>
501 )}
502 <Context.Provider value={context}>
503 {typeof children === 'function' ? children(context) : children}
504 </Context.Provider>
505 </PressableComponent>
506 )
507 },
508)
509Button.displayName = 'Button'
510
511export function useSharedButtonTextStyles() {
512 const t = useTheme()
513 const {color, variant, disabled, size} = useButtonContext()
514 return React.useMemo(() => {
515 const baseStyles: TextStyle[] = []
516
517 if (color === 'primary') {
518 if (variant === 'solid') {
519 if (!disabled) {
520 baseStyles.push({color: t.palette.white})
521 } else {
522 baseStyles.push({color: t.palette.white, opacity: 0.5})
523 }
524 } else if (variant === 'outline') {
525 if (!disabled) {
526 baseStyles.push({
527 color: t.palette.primary_600,
528 })
529 } else {
530 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
531 }
532 } else if (variant === 'ghost') {
533 if (!disabled) {
534 baseStyles.push({color: t.palette.primary_600})
535 } else {
536 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
537 }
538 }
539 } else if (color === 'secondary') {
540 if (variant === 'solid' || variant === 'gradient') {
541 if (!disabled) {
542 baseStyles.push({
543 color: t.palette.contrast_700,
544 })
545 } else {
546 baseStyles.push({
547 color: t.palette.contrast_400,
548 })
549 }
550 } else if (variant === 'outline') {
551 if (!disabled) {
552 baseStyles.push({
553 color: t.palette.contrast_600,
554 })
555 } else {
556 baseStyles.push({
557 color: t.palette.contrast_300,
558 })
559 }
560 } else if (variant === 'ghost') {
561 if (!disabled) {
562 baseStyles.push({
563 color: t.palette.contrast_600,
564 })
565 } else {
566 baseStyles.push({
567 color: t.palette.contrast_300,
568 })
569 }
570 }
571 } else if (color === 'secondary_inverted') {
572 if (variant === 'solid' || variant === 'gradient') {
573 if (!disabled) {
574 baseStyles.push({
575 color: t.palette.contrast_100,
576 })
577 } else {
578 baseStyles.push({
579 color: t.palette.contrast_400,
580 })
581 }
582 } else if (variant === 'outline') {
583 if (!disabled) {
584 baseStyles.push({
585 color: t.palette.contrast_600,
586 })
587 } else {
588 baseStyles.push({
589 color: t.palette.contrast_300,
590 })
591 }
592 } else if (variant === 'ghost') {
593 if (!disabled) {
594 baseStyles.push({
595 color: t.palette.contrast_600,
596 })
597 } else {
598 baseStyles.push({
599 color: t.palette.contrast_300,
600 })
601 }
602 }
603 } else if (color === 'negative') {
604 if (variant === 'solid' || variant === 'gradient') {
605 if (!disabled) {
606 baseStyles.push({color: t.palette.white})
607 } else {
608 baseStyles.push({color: t.palette.white, opacity: 0.5})
609 }
610 } else if (variant === 'outline') {
611 if (!disabled) {
612 baseStyles.push({color: t.palette.negative_400})
613 } else {
614 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
615 }
616 } else if (variant === 'ghost') {
617 if (!disabled) {
618 baseStyles.push({color: t.palette.negative_400})
619 } else {
620 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
621 }
622 }
623 } else {
624 if (!disabled) {
625 baseStyles.push({color: t.palette.white})
626 } else {
627 baseStyles.push({color: t.palette.white, opacity: 0.5})
628 }
629 }
630
631 if (size === 'large') {
632 baseStyles.push(a.text_md, a.leading_tight)
633 } else if (size === 'small') {
634 baseStyles.push(a.text_sm, a.leading_tight)
635 } else if (size === 'tiny') {
636 baseStyles.push(a.text_xs, a.leading_tight)
637 }
638
639 return StyleSheet.flatten(baseStyles)
640 }, [t, variant, color, size, disabled])
641}
642
643export function ButtonText({children, style, ...rest}: ButtonTextProps) {
644 const textStyles = useSharedButtonTextStyles()
645
646 return (
647 <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
648 {children}
649 </Text>
650 )
651}
652
653export function ButtonIcon({
654 icon: Comp,
655 position,
656 size,
657}: {
658 icon: React.ComponentType<SVGIconProps>
659 position?: 'left' | 'right'
660 size?: SVGIconProps['size']
661}) {
662 const {size: buttonSize, disabled} = useButtonContext()
663 const textStyles = useSharedButtonTextStyles()
664 const {iconSize, iconContainerSize} = React.useMemo(() => {
665 /**
666 * Pre-set icon sizes for different button sizes
667 */
668 const iconSizeShorthand =
669 size ??
670 (({
671 large: 'sm',
672 small: 'sm',
673 tiny: 'xs',
674 }[buttonSize || 'small'] || 'sm') as Exclude<
675 SVGIconProps['size'],
676 undefined
677 >)
678
679 /*
680 * Copied here from icons/common.tsx so we can tweak if we need to, but
681 * also so that we can calculate transforms.
682 */
683 const iconSize = {
684 xs: 12,
685 sm: 16,
686 md: 20,
687 lg: 24,
688 xl: 28,
689 '2xl': 32,
690 }[iconSizeShorthand]
691
692 /*
693 * Goal here is to match rendered text size so that different size icons
694 * don't increase button size
695 */
696 const iconContainerSize = {
697 large: 18,
698 small: 16,
699 tiny: 13,
700 }[buttonSize || 'small']
701
702 return {
703 iconSize,
704 iconContainerSize,
705 }
706 }, [buttonSize, size])
707
708 return (
709 <View
710 style={[
711 a.z_20,
712 {
713 width: iconContainerSize,
714 height: iconContainerSize,
715 opacity: disabled ? 0.7 : 1,
716 marginLeft: position === 'left' ? -2 : 0,
717 marginRight: position === 'right' ? -2 : 0,
718 },
719 ]}>
720 <View
721 style={[
722 a.absolute,
723 {
724 width: iconSize,
725 height: iconSize,
726 top: '50%',
727 left: '50%',
728 transform: [
729 {
730 translateX: (iconSize / 2) * -1,
731 },
732 {
733 translateY: (iconSize / 2) * -1,
734 },
735 ],
736 },
737 ]}>
738 <Comp
739 width={iconSize}
740 style={[
741 {
742 color: textStyles.color,
743 pointerEvents: 'none',
744 },
745 ]}
746 />
747 </View>
748 </View>
749 )
750}