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
75 | Iterable<React.ReactElement | 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_25)
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: select(t.name, {
293 light: t.palette.primary_25,
294 dim: t.palette.primary_50,
295 dark: t.palette.primary_50,
296 }),
297 })
298 }
299 } else if (color === 'negative_subtle') {
300 if (!disabled) {
301 baseStyles.push({
302 backgroundColor: select(t.name, {
303 light: t.palette.negative_50,
304 dim: t.palette.negative_100,
305 dark: t.palette.negative_100,
306 }),
307 })
308 hoverStyles.push({
309 backgroundColor: select(t.name, {
310 light: t.palette.negative_100,
311 dim: t.palette.negative_200,
312 dark: t.palette.negative_200,
313 }),
314 })
315 } else {
316 baseStyles.push({
317 backgroundColor: select(t.name, {
318 light: t.palette.negative_25,
319 dim: t.palette.negative_50,
320 dark: t.palette.negative_50,
321 }),
322 })
323 }
324 }
325 } else {
326 /*
327 * BEGIN DEPRECATED STYLES
328 */
329 if (color === 'primary') {
330 if (variant === 'outline') {
331 baseStyles.push(a.border, t.atoms.bg, {
332 borderWidth: 1,
333 })
334
335 if (!disabled) {
336 baseStyles.push(a.border, {
337 borderColor: t.palette.primary_500,
338 })
339 hoverStyles.push(a.border, {
340 backgroundColor: t.palette.primary_50,
341 })
342 } else {
343 baseStyles.push(a.border, {
344 borderColor: t.palette.primary_200,
345 })
346 }
347 } else if (variant === 'ghost') {
348 if (!disabled) {
349 baseStyles.push(t.atoms.bg)
350 hoverStyles.push({
351 backgroundColor: t.palette.primary_100,
352 })
353 }
354 }
355 } else if (color === 'secondary') {
356 if (variant === 'outline') {
357 baseStyles.push(a.border, t.atoms.bg, {
358 borderWidth: 1,
359 })
360
361 if (!disabled) {
362 baseStyles.push(a.border, {
363 borderColor: t.palette.contrast_300,
364 })
365 hoverStyles.push(t.atoms.bg_contrast_50)
366 } else {
367 baseStyles.push(a.border, {
368 borderColor: t.palette.contrast_200,
369 })
370 }
371 } else if (variant === 'ghost') {
372 if (!disabled) {
373 baseStyles.push(t.atoms.bg)
374 hoverStyles.push({
375 backgroundColor: t.palette.contrast_25,
376 })
377 }
378 }
379 } else if (color === 'secondary_inverted') {
380 if (variant === 'outline') {
381 baseStyles.push(a.border, t.atoms.bg, {
382 borderWidth: 1,
383 })
384
385 if (!disabled) {
386 baseStyles.push(a.border, {
387 borderColor: t.palette.contrast_300,
388 })
389 hoverStyles.push(t.atoms.bg_contrast_50)
390 } else {
391 baseStyles.push(a.border, {
392 borderColor: t.palette.contrast_200,
393 })
394 }
395 } else if (variant === 'ghost') {
396 if (!disabled) {
397 baseStyles.push(t.atoms.bg)
398 hoverStyles.push({
399 backgroundColor: t.palette.contrast_25,
400 })
401 }
402 }
403 } else if (color === 'negative') {
404 if (variant === 'outline') {
405 baseStyles.push(a.border, t.atoms.bg, {
406 borderWidth: 1,
407 })
408
409 if (!disabled) {
410 baseStyles.push(a.border, {
411 borderColor: t.palette.negative_500,
412 })
413 hoverStyles.push(a.border, {
414 backgroundColor: t.palette.negative_50,
415 })
416 } else {
417 baseStyles.push(a.border, {
418 borderColor: t.palette.negative_200,
419 })
420 }
421 } else if (variant === 'ghost') {
422 if (!disabled) {
423 baseStyles.push(t.atoms.bg)
424 hoverStyles.push({
425 backgroundColor: t.palette.negative_100,
426 })
427 }
428 }
429 } else if (color === 'negative_subtle') {
430 if (variant === 'outline') {
431 baseStyles.push(a.border, t.atoms.bg, {
432 borderWidth: 1,
433 })
434
435 if (!disabled) {
436 baseStyles.push(a.border, {
437 borderColor: t.palette.negative_500,
438 })
439 hoverStyles.push(a.border, {
440 backgroundColor: t.palette.negative_50,
441 })
442 } else {
443 baseStyles.push(a.border, {
444 borderColor: t.palette.negative_200,
445 })
446 }
447 } else if (variant === 'ghost') {
448 if (!disabled) {
449 baseStyles.push(t.atoms.bg)
450 hoverStyles.push({
451 backgroundColor: t.palette.negative_100,
452 })
453 }
454 }
455 }
456 /*
457 * END DEPRECATED STYLES
458 */
459 }
460
461 if (shape === 'default') {
462 if (size === 'large') {
463 baseStyles.push({
464 paddingVertical: 12,
465 paddingHorizontal: 25,
466 borderRadius: 10,
467 gap: 3,
468 })
469 } else if (size === 'small') {
470 baseStyles.push({
471 paddingVertical: 8,
472 paddingHorizontal: 13,
473 borderRadius: 8,
474 gap: 3,
475 })
476 } else if (size === 'tiny') {
477 baseStyles.push({
478 paddingVertical: 5,
479 paddingHorizontal: 9,
480 borderRadius: 6,
481 gap: 2,
482 })
483 }
484 } else if (shape === 'round' || shape === 'square') {
485 /*
486 * These sizes match the actual rendered size on screen, based on
487 * Chrome's web inspector
488 */
489 if (size === 'large') {
490 if (shape === 'round') {
491 baseStyles.push({height: 44, width: 44})
492 } else {
493 baseStyles.push({height: 44, width: 44})
494 }
495 } else if (size === 'small') {
496 if (shape === 'round') {
497 baseStyles.push({height: 33, width: 33})
498 } else {
499 baseStyles.push({height: 33, width: 33})
500 }
501 } else if (size === 'tiny') {
502 if (shape === 'round') {
503 baseStyles.push({height: 25, width: 25})
504 } else {
505 baseStyles.push({height: 25, width: 25})
506 }
507 }
508
509 if (shape === 'round') {
510 baseStyles.push(a.rounded_full)
511 } else if (shape === 'square') {
512 if (size === 'tiny') {
513 baseStyles.push({
514 borderRadius: 6,
515 })
516 } else {
517 baseStyles.push(a.rounded_sm)
518 }
519 }
520 }
521
522 return {
523 baseStyles,
524 hoverStyles,
525 }
526 }, [t, variant, color, size, shape, disabled])
527
528 const context = React.useMemo<ButtonContext>(
529 () => ({
530 ...state,
531 variant,
532 color,
533 size,
534 disabled: disabled || false,
535 }),
536 [state, variant, color, size, disabled],
537 )
538
539 const flattenedBaseStyles = flatten([baseStyles, style])
540
541 return (
542 <PressableComponent
543 role="button"
544 accessibilityHint={undefined} // optional
545 {...rest}
546 // @ts-ignore - this will always be a pressable
547 ref={ref}
548 aria-label={label}
549 aria-pressed={state.pressed}
550 accessibilityLabel={label}
551 disabled={disabled || false}
552 accessibilityState={{
553 disabled: disabled || false,
554 }}
555 style={[
556 a.flex_row,
557 a.align_center,
558 a.justify_center,
559 a.curve_continuous,
560 flattenedBaseStyles,
561 ...(state.hovered || state.pressed
562 ? [hoverStyles, flatten(hoverStyleProp)]
563 : []),
564 ]}
565 onPressIn={onPressIn}
566 onPressOut={onPressOut}
567 onHoverIn={onHoverIn}
568 onHoverOut={onHoverOut}
569 onFocus={onFocus}
570 onBlur={onBlur}>
571 <Context.Provider value={context}>
572 {typeof children === 'function' ? children(context) : children}
573 </Context.Provider>
574 </PressableComponent>
575 )
576 },
577)
578Button.displayName = 'Button'
579
580export function useSharedButtonTextStyles() {
581 const t = useTheme()
582 const {color, variant, disabled, size} = useButtonContext()
583 return React.useMemo(() => {
584 const baseStyles: TextStyle[] = []
585
586 /*
587 * This is the happy path for new button styles, following the
588 * deprecation of `variant` prop. This redundant `variant` check is here
589 * just to make this handling easier to understand.
590 */
591 if (variant === 'solid') {
592 if (color === 'primary') {
593 if (!disabled) {
594 baseStyles.push({color: t.palette.white})
595 } else {
596 baseStyles.push({
597 color: select(t.name, {
598 light: t.palette.white,
599 dim: t.atoms.text_inverted.color,
600 dark: t.atoms.text_inverted.color,
601 }),
602 })
603 }
604 } else if (color === 'secondary') {
605 if (!disabled) {
606 baseStyles.push(t.atoms.text_contrast_medium)
607 } else {
608 baseStyles.push({
609 color: t.palette.contrast_300,
610 })
611 }
612 } else if (color === 'secondary_inverted') {
613 if (!disabled) {
614 baseStyles.push(t.atoms.text_inverted)
615 } else {
616 baseStyles.push({
617 color: t.palette.contrast_300,
618 })
619 }
620 } else if (color === 'negative') {
621 if (!disabled) {
622 baseStyles.push({color: t.palette.white})
623 } else {
624 baseStyles.push({color: t.palette.negative_300})
625 }
626 } else if (color === 'primary_subtle') {
627 if (!disabled) {
628 baseStyles.push({
629 color: select(t.name, {
630 light: t.palette.primary_600,
631 dim: t.palette.primary_800,
632 dark: t.palette.primary_800,
633 }),
634 })
635 } else {
636 baseStyles.push({
637 color: select(t.name, {
638 light: t.palette.primary_200,
639 dim: t.palette.primary_200,
640 dark: t.palette.primary_200,
641 }),
642 })
643 }
644 } else if (color === 'negative_subtle') {
645 if (!disabled) {
646 baseStyles.push({
647 color: select(t.name, {
648 light: t.palette.negative_600,
649 dim: t.palette.negative_800,
650 dark: t.palette.negative_800,
651 }),
652 })
653 } else {
654 baseStyles.push({
655 color: select(t.name, {
656 light: t.palette.negative_200,
657 dim: t.palette.negative_200,
658 dark: t.palette.negative_200,
659 }),
660 })
661 }
662 }
663 } else {
664 /*
665 * BEGIN DEPRECATED STYLES
666 */
667 if (color === 'primary') {
668 if (variant === 'outline') {
669 if (!disabled) {
670 baseStyles.push({
671 color: t.palette.primary_600,
672 })
673 } else {
674 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
675 }
676 } else if (variant === 'ghost') {
677 if (!disabled) {
678 baseStyles.push({color: t.palette.primary_600})
679 } else {
680 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
681 }
682 }
683 } else if (color === 'secondary') {
684 if (variant === 'outline') {
685 if (!disabled) {
686 baseStyles.push({
687 color: t.palette.contrast_600,
688 })
689 } else {
690 baseStyles.push({
691 color: t.palette.contrast_300,
692 })
693 }
694 } else if (variant === 'ghost') {
695 if (!disabled) {
696 baseStyles.push({
697 color: t.palette.contrast_600,
698 })
699 } else {
700 baseStyles.push({
701 color: t.palette.contrast_300,
702 })
703 }
704 }
705 } else if (color === 'secondary_inverted') {
706 if (variant === 'outline') {
707 if (!disabled) {
708 baseStyles.push({
709 color: t.palette.contrast_600,
710 })
711 } else {
712 baseStyles.push({
713 color: t.palette.contrast_300,
714 })
715 }
716 } else if (variant === 'ghost') {
717 if (!disabled) {
718 baseStyles.push({
719 color: t.palette.contrast_600,
720 })
721 } else {
722 baseStyles.push({
723 color: t.palette.contrast_300,
724 })
725 }
726 }
727 } else if (color === 'negative') {
728 if (variant === 'outline') {
729 if (!disabled) {
730 baseStyles.push({color: t.palette.negative_400})
731 } else {
732 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
733 }
734 } else if (variant === 'ghost') {
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 }
741 } else if (color === 'negative_subtle') {
742 if (variant === 'outline') {
743 if (!disabled) {
744 baseStyles.push({color: t.palette.negative_400})
745 } else {
746 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
747 }
748 } else if (variant === 'ghost') {
749 if (!disabled) {
750 baseStyles.push({color: t.palette.negative_400})
751 } else {
752 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
753 }
754 }
755 }
756 /*
757 * END DEPRECATED STYLES
758 */
759 }
760
761 if (size === 'large') {
762 baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
763 } else if (size === 'small') {
764 baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
765 } else if (size === 'tiny') {
766 baseStyles.push(a.text_xs, a.leading_snug, a.font_medium)
767 }
768
769 return StyleSheet.flatten(baseStyles)
770 }, [t, variant, color, size, disabled])
771}
772
773export function ButtonText({children, style, ...rest}: ButtonTextProps) {
774 const textStyles = useSharedButtonTextStyles()
775
776 return (
777 <Text {...rest} style={[a.text_center, textStyles, style]}>
778 {children}
779 </Text>
780 )
781}
782
783export function ButtonIcon({
784 icon: Comp,
785 size,
786}: {
787 icon: React.ComponentType<SVGIconProps>
788 /**
789 * @deprecated no longer needed
790 */
791 position?: 'left' | 'right'
792 size?: SVGIconProps['size']
793}) {
794 const {size: buttonSize} = useButtonContext()
795 const textStyles = useSharedButtonTextStyles()
796 const {iconSize, iconContainerSize} = React.useMemo(() => {
797 /**
798 * Pre-set icon sizes for different button sizes
799 */
800 const iconSizeShorthand =
801 size ??
802 (({
803 large: 'md',
804 small: 'sm',
805 tiny: 'xs',
806 }[buttonSize || 'small'] || 'sm') as Exclude<
807 SVGIconProps['size'],
808 undefined
809 >)
810
811 /*
812 * Copied here from icons/common.tsx so we can tweak if we need to, but
813 * also so that we can calculate transforms.
814 */
815 const iconSize = {
816 xs: 12,
817 sm: 16,
818 md: 18,
819 lg: 24,
820 xl: 28,
821 '2xl': 32,
822 }[iconSizeShorthand]
823
824 /*
825 * Goal here is to match rendered text size so that different size icons
826 * don't increase button size
827 */
828 const iconContainerSize = {
829 large: 20,
830 small: 17,
831 tiny: 15,
832 }[buttonSize || 'small']
833
834 return {
835 iconSize,
836 iconContainerSize,
837 }
838 }, [buttonSize, size])
839
840 return (
841 <View
842 style={[
843 a.z_20,
844 {
845 width: iconContainerSize,
846 height: iconContainerSize,
847 },
848 ]}>
849 <View
850 style={[
851 a.absolute,
852 {
853 width: iconSize,
854 height: iconSize,
855 top: '50%',
856 left: '50%',
857 transform: [
858 {
859 translateX: (iconSize / 2) * -1,
860 },
861 {
862 translateY: (iconSize / 2) * -1,
863 },
864 ],
865 },
866 ]}>
867 <Comp
868 width={iconSize}
869 style={[
870 {
871 color: textStyles.color,
872 pointerEvents: 'none',
873 },
874 ]}
875 />
876 </View>
877 </View>
878 )
879}