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