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