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