Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {
2 createContext,
3 forwardRef,
4 useCallback,
5 useContext,
6 useMemo,
7 useState,
8} from 'react'
9import {
10 type AccessibilityProps,
11 type GestureResponderEvent,
12 type MouseEvent,
13 type NativeSyntheticEvent,
14 Pressable,
15 type PressableProps,
16 type StyleProp,
17 type TargetedEvent,
18 type TextProps,
19 type TextStyle,
20 View,
21 type ViewStyle,
22} from 'react-native'
23
24import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
25import {useThemePrefs} from '#/state/shell'
26import {atoms as a, flatten, select, useTheme} from '#/alf'
27import {type Props as SVGIconProps} from '#/components/icons/common'
28import {Text} from '#/components/Typography'
29
30/**
31 * The `Button` component, and some extensions of it like `Link` are intended
32 * to be generic and therefore apply no styles by default. These `VariantProps`
33 * are what control the `Button`'s presentation, and are intended only use cases where the buttons appear as, well, buttons.
34 *
35 * 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.
36 *
37 * @example
38 * type MyComponentProps = Omit<ButtonProps, UninheritableButtonProps> & {...}
39 */
40export type UninheritableButtonProps = 'variant' | 'color' | 'size' | 'shape'
41
42export type ButtonVariant = 'solid' | 'outline' | 'ghost'
43export type ButtonColor =
44 | 'primary'
45 | 'secondary'
46 | 'secondary_inverted'
47 | 'negative'
48 | 'primary_subtle'
49 | 'negative_subtle'
50export type ButtonSize = 'tiny' | 'small' | 'large'
51export type ButtonShape = 'round' | 'square' | 'rectangular' | 'default'
52export type VariantProps = {
53 /**
54 * The style variation of the button
55 * @deprecated Use `color` instead.
56 */
57 variant?: ButtonVariant
58 /**
59 * The color of the button
60 */
61 color?: ButtonColor
62 /**
63 * The size of the button
64 */
65 size?: ButtonSize
66 /**
67 * The shape of the button
68 *
69 * - `default`: Pill shaped. Most buttons should use this shape.
70 * - `round`: Circular. For icon-only buttons.
71 * - `square`: Square. For icon-only buttons.
72 * - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields.
73 */
74 shape?: ButtonShape
75}
76
77export type ButtonState = {
78 hovered: boolean
79 focused: boolean
80 pressed: boolean
81 disabled: boolean
82}
83
84export type ButtonContext = VariantProps & ButtonState
85
86type NonTextElements =
87 | React.ReactElement<any>
88 | Iterable<React.ReactElement<any> | null | undefined | boolean>
89
90export type ButtonProps = Pick<
91 PressableProps,
92 | 'disabled'
93 | 'onPress'
94 | 'testID'
95 | 'onLongPress'
96 | 'hitSlop'
97 | 'onHoverIn'
98 | 'onHoverOut'
99 | 'onPressIn'
100 | 'onPressOut'
101 | 'onFocus'
102 | 'onBlur'
103> &
104 AccessibilityProps &
105 VariantProps & {
106 testID?: string
107 /**
108 * For a11y, try to make this descriptive and clear
109 */
110 label: string
111 style?: StyleProp<ViewStyle>
112 hoverStyle?: StyleProp<ViewStyle>
113 children: NonTextElements | ((context: ButtonContext) => NonTextElements)
114 PressableComponent?: React.ComponentType<PressableProps>
115 }
116
117export type ButtonTextProps = TextProps &
118 VariantProps & {disabled?: boolean; emoji?: boolean}
119
120const Context = createContext<VariantProps & ButtonState>({
121 hovered: false,
122 focused: false,
123 pressed: false,
124 disabled: false,
125})
126Context.displayName = 'ButtonContext'
127
128export function useButtonContext() {
129 return useContext(Context)
130}
131
132export const Button = forwardRef<View, ButtonProps>(
133 (
134 {
135 children,
136 variant,
137 color,
138 size,
139 shape = 'default',
140 label,
141 disabled = false,
142 style,
143 hoverStyle: hoverStyleProp,
144 PressableComponent = Pressable,
145 onPressIn: onPressInOuter,
146 onPressOut: onPressOutOuter,
147 onHoverIn: onHoverInOuter,
148 onHoverOut: onHoverOutOuter,
149 onFocus: onFocusOuter,
150 onBlur: onBlurOuter,
151 ...rest
152 },
153 ref,
154 ) => {
155 /**
156 * The `variant` prop is deprecated in favor of simply specifying `color`.
157 * If a `color` is set, then we want to use the existing codepaths for
158 * "solid" buttons. This is to maintain backwards compatibility.
159 */
160 if (!variant && color) {
161 variant = 'solid'
162 }
163
164 const enableSquareButtons = useEnableSquareButtons()
165
166 const t = useTheme()
167 const [state, setState] = useState({
168 pressed: false,
169 hovered: false,
170 focused: false,
171 })
172
173 const onPressIn = useCallback(
174 (e: GestureResponderEvent) => {
175 setState(s => ({
176 ...s,
177 pressed: true,
178 }))
179 onPressInOuter?.(e)
180 },
181 [setState, onPressInOuter],
182 )
183 const onPressOut = useCallback(
184 (e: GestureResponderEvent) => {
185 setState(s => ({
186 ...s,
187 pressed: false,
188 }))
189 onPressOutOuter?.(e)
190 },
191 [setState, onPressOutOuter],
192 )
193 const onHoverIn = useCallback(
194 (e: MouseEvent) => {
195 setState(s => ({
196 ...s,
197 hovered: true,
198 }))
199 onHoverInOuter?.(e)
200 },
201 [setState, onHoverInOuter],
202 )
203 const onHoverOut = useCallback(
204 (e: MouseEvent) => {
205 setState(s => ({
206 ...s,
207 hovered: false,
208 }))
209 onHoverOutOuter?.(e)
210 },
211 [setState, onHoverOutOuter],
212 )
213 const onFocus = useCallback(
214 (e: NativeSyntheticEvent<TargetedEvent>) => {
215 setState(s => ({
216 ...s,
217 focused: true,
218 }))
219 onFocusOuter?.(e)
220 },
221 [setState, onFocusOuter],
222 )
223 const onBlur = useCallback(
224 (e: NativeSyntheticEvent<TargetedEvent>) => {
225 setState(s => ({
226 ...s,
227 focused: false,
228 }))
229 onBlurOuter?.(e)
230 },
231 [setState, onBlurOuter],
232 )
233
234 const {baseStyles, hoverStyles} = useMemo(() => {
235 const baseStyles: ViewStyle[] = []
236 const hoverStyles: ViewStyle[] = []
237
238 /*
239 * This is the happy path for new button styles, following the
240 * deprecation of `variant` prop. This redundant `variant` check is here
241 * just to make this handling easier to understand.
242 */
243 if (variant === 'solid') {
244 if (color === 'primary') {
245 if (!disabled) {
246 baseStyles.push({
247 backgroundColor: t.palette.primary_500,
248 })
249 hoverStyles.push({
250 backgroundColor: t.palette.primary_600,
251 })
252 } else {
253 baseStyles.push({
254 backgroundColor: t.palette.primary_200,
255 })
256 }
257 } else if (color === 'secondary') {
258 if (!disabled) {
259 baseStyles.push(t.atoms.bg_contrast_50)
260 hoverStyles.push(t.atoms.bg_contrast_100)
261 } else {
262 baseStyles.push(t.atoms.bg_contrast_50)
263 }
264 } else if (color === 'secondary_inverted') {
265 if (!disabled) {
266 baseStyles.push({
267 backgroundColor: t.palette.contrast_900,
268 })
269 hoverStyles.push({
270 backgroundColor: t.palette.contrast_975,
271 })
272 } else {
273 baseStyles.push({
274 backgroundColor: t.palette.contrast_600,
275 })
276 }
277 } else if (color === 'negative') {
278 if (!disabled) {
279 baseStyles.push({
280 backgroundColor: t.palette.negative_500,
281 })
282 hoverStyles.push({
283 backgroundColor: t.palette.negative_600,
284 })
285 } else {
286 baseStyles.push({
287 backgroundColor: t.palette.negative_700,
288 })
289 }
290 } else if (color === 'primary_subtle') {
291 if (!disabled) {
292 baseStyles.push({
293 backgroundColor: t.palette.primary_50,
294 })
295 hoverStyles.push({
296 backgroundColor: t.palette.primary_100,
297 })
298 } else {
299 baseStyles.push({
300 backgroundColor: t.palette.primary_50,
301 })
302 }
303 } else if (color === 'negative_subtle') {
304 if (!disabled) {
305 baseStyles.push({
306 backgroundColor: t.palette.negative_50,
307 })
308 hoverStyles.push({
309 backgroundColor: t.palette.negative_100,
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(enableSquareButtons ? a.rounded_sm : a.rounded_full, {
456 paddingVertical: 12,
457 paddingHorizontal: 24,
458 gap: 6,
459 })
460 } else if (size === 'small') {
461 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, {
462 paddingVertical: 8,
463 paddingHorizontal: 14,
464 gap: 5,
465 })
466 } else if (size === 'tiny') {
467 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, {
468 paddingVertical: 5,
469 paddingHorizontal: 10,
470 gap: 3,
471 })
472 }
473 } else if (shape === 'rectangular') {
474 if (size === 'large') {
475 baseStyles.push({
476 paddingVertical: 12,
477 paddingHorizontal: 25,
478 borderRadius: 10,
479 gap: 3,
480 })
481 } else if (size === 'small') {
482 baseStyles.push({
483 paddingVertical: 8,
484 paddingHorizontal: 13,
485 borderRadius: 8,
486 gap: 3,
487 })
488 } else if (size === 'tiny') {
489 baseStyles.push({
490 paddingVertical: 5,
491 paddingHorizontal: 9,
492 borderRadius: 6,
493 gap: 2,
494 })
495 }
496 } else if (shape === 'round' || shape === 'square') {
497 /*
498 * These sizes match the actual rendered size on screen, based on
499 * Chrome's web inspector
500 */
501 if (size === 'large') {
502 if (shape === 'round') {
503 baseStyles.push({height: 44, width: 44})
504 } else {
505 baseStyles.push({height: 44, width: 44})
506 }
507 } else if (size === 'small') {
508 if (shape === 'round') {
509 baseStyles.push({height: 33, width: 33})
510 } else {
511 baseStyles.push({height: 33, width: 33})
512 }
513 } else if (size === 'tiny') {
514 if (shape === 'round') {
515 baseStyles.push({height: 25, width: 25})
516 } else {
517 baseStyles.push({height: 25, width: 25})
518 }
519 }
520
521 if (shape === 'round') {
522 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full)
523 } else if (shape === 'square') {
524 if (size === 'tiny') {
525 baseStyles.push({
526 borderRadius: 6,
527 })
528 } else {
529 baseStyles.push(a.rounded_sm)
530 }
531 }
532 }
533
534 return {
535 baseStyles,
536 hoverStyles,
537 }
538 }, [t, variant, color, size, shape, disabled, enableSquareButtons])
539
540 const context = useMemo<ButtonContext>(
541 () => ({
542 ...state,
543 variant,
544 color,
545 size,
546 shape,
547 disabled: disabled || false,
548 }),
549 [state, variant, color, size, shape, disabled],
550 )
551
552 return (
553 <PressableComponent
554 role="button"
555 accessibilityHint={undefined} // optional
556 {...rest}
557 // @ts-ignore - this will always be a pressable
558 ref={ref}
559 aria-label={label}
560 aria-pressed={state.pressed}
561 accessibilityLabel={label}
562 disabled={disabled || false}
563 accessibilityState={{
564 disabled: disabled || false,
565 }}
566 style={[
567 a.flex_row,
568 a.align_center,
569 a.justify_center,
570 a.curve_continuous,
571 baseStyles,
572 style,
573 ...(state.hovered || state.pressed
574 ? [hoverStyles, hoverStyleProp]
575 : []),
576 ]}
577 onPressIn={onPressIn}
578 onPressOut={onPressOut}
579 onHoverIn={onHoverIn}
580 onHoverOut={onHoverOut}
581 onFocus={onFocus}
582 onBlur={onBlur}>
583 <Context.Provider value={context}>
584 {typeof children === 'function' ? children(context) : children}
585 </Context.Provider>
586 </PressableComponent>
587 )
588 },
589)
590Button.displayName = 'Button'
591
592export function useSharedButtonTextStyles() {
593 const t = useTheme()
594 const {color, variant, disabled, size} = useButtonContext()
595 const {colorScheme} = useThemePrefs()
596
597 return useMemo(() => {
598 const baseStyles: TextStyle[] = []
599
600 /*
601 * This is the happy path for new button styles, following the
602 * deprecation of `variant` prop. This redundant `variant` check is here
603 * just to make this handling easier to understand.
604 */
605 if (variant === 'solid') {
606 if (color === 'primary') {
607 if (!disabled) {
608 baseStyles.push({color: t.palette.white})
609 } else {
610 baseStyles.push({
611 color: select(t.name, {
612 light: t.palette.white,
613 dim: t.atoms.text_inverted.color,
614 dark: t.atoms.text_inverted.color,
615 }),
616 })
617 }
618 } else if (color === 'secondary') {
619 if (!disabled) {
620 baseStyles.push(t.atoms.text_contrast_medium)
621 } else {
622 baseStyles.push({
623 color: t.palette.contrast_300,
624 })
625 }
626 } else if (color === 'secondary_inverted') {
627 if (!disabled) {
628 baseStyles.push(t.atoms.text_inverted)
629 } else {
630 baseStyles.push({
631 color: t.palette.contrast_300,
632 })
633 }
634 } else if (color === 'negative') {
635 if (!disabled) {
636 baseStyles.push({color: t.palette.white})
637 } else {
638 baseStyles.push({color: t.palette.negative_300})
639 }
640 } else if (color === 'primary_subtle') {
641 if (!disabled) {
642 baseStyles.push({
643 color: t.palette.primary_600,
644 })
645 } else {
646 if (colorScheme === 'material3') {
647 // ugly hack! really alf should export these colors as atoms
648 baseStyles.push({
649 color: t.palette.primary_400,
650 })
651 } else {
652 baseStyles.push({
653 color: t.palette.primary_200,
654 })
655 }
656 }
657 } else if (color === 'negative_subtle') {
658 if (!disabled) {
659 baseStyles.push({
660 color: t.palette.negative_600,
661 })
662 } else {
663 baseStyles.push({
664 color: t.palette.negative_200,
665 })
666 }
667 }
668 } else {
669 /*
670 * BEGIN DEPRECATED STYLES
671 */
672 if (color === 'primary') {
673 if (variant === 'outline') {
674 if (!disabled) {
675 baseStyles.push({
676 color: t.palette.primary_600,
677 })
678 } else {
679 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
680 }
681 } else if (variant === 'ghost') {
682 if (!disabled) {
683 baseStyles.push({color: t.palette.primary_600})
684 } else {
685 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
686 }
687 }
688 } else if (color === 'secondary') {
689 if (variant === 'outline') {
690 if (!disabled) {
691 baseStyles.push({
692 color: t.palette.contrast_600,
693 })
694 } else {
695 baseStyles.push({
696 color: t.palette.contrast_300,
697 })
698 }
699 } else if (variant === 'ghost') {
700 if (!disabled) {
701 baseStyles.push({
702 color: t.palette.contrast_600,
703 })
704 } else {
705 baseStyles.push({
706 color: t.palette.contrast_300,
707 })
708 }
709 }
710 } else if (color === 'secondary_inverted') {
711 if (variant === 'outline') {
712 if (!disabled) {
713 baseStyles.push({
714 color: t.palette.contrast_600,
715 })
716 } else {
717 baseStyles.push({
718 color: t.palette.contrast_300,
719 })
720 }
721 } else if (variant === 'ghost') {
722 if (!disabled) {
723 baseStyles.push({
724 color: t.palette.contrast_600,
725 })
726 } else {
727 baseStyles.push({
728 color: t.palette.contrast_300,
729 })
730 }
731 }
732 } else if (color === 'negative') {
733 if (variant === 'outline') {
734 if (!disabled) {
735 baseStyles.push({color: t.palette.negative_400})
736 } else {
737 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
738 }
739 } else if (variant === 'ghost') {
740 if (!disabled) {
741 baseStyles.push({color: t.palette.negative_400})
742 } else {
743 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
744 }
745 }
746 } else if (color === 'negative_subtle') {
747 if (variant === 'outline') {
748 if (!disabled) {
749 baseStyles.push({color: t.palette.negative_400})
750 } else {
751 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
752 }
753 } else if (variant === 'ghost') {
754 if (!disabled) {
755 baseStyles.push({color: t.palette.negative_400})
756 } else {
757 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
758 }
759 }
760 }
761 /*
762 * END DEPRECATED STYLES
763 */
764 }
765
766 if (size === 'large') {
767 baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
768 } else if (size === 'small') {
769 baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
770 } else if (size === 'tiny') {
771 baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
772 }
773
774 return flatten(baseStyles)
775 }, [t, variant, color, size, disabled, colorScheme])
776}
777
778export function ButtonText({children, style, ...rest}: ButtonTextProps) {
779 const textStyles = useSharedButtonTextStyles()
780
781 return (
782 <Text {...rest} style={[a.text_center, textStyles, style]}>
783 {children}
784 </Text>
785 )
786}
787
788export function ButtonIcon({
789 icon: Comp,
790 size,
791}: {
792 icon: React.ComponentType<SVGIconProps>
793 /**
794 * @deprecated no longer needed
795 */
796 position?: 'left' | 'right'
797 size?: SVGIconProps['size']
798}) {
799 const {size: buttonSize, shape: buttonShape} = useButtonContext()
800 const textStyles = useSharedButtonTextStyles()
801 const {iconSize, iconContainerSize, iconNegativeMargin} = useMemo(() => {
802 /**
803 * Pre-set icon sizes for different button sizes
804 */
805 const iconSizeShorthand =
806 size ??
807 (({
808 large: 'md',
809 small: 'sm',
810 tiny: 'xs',
811 }[buttonSize || 'small'] || 'sm') as Exclude<
812 SVGIconProps['size'],
813 undefined
814 >)
815
816 /*
817 * Copied here from icons/common.tsx so we can tweak if we need to, but
818 * also so that we can calculate transforms.
819 */
820 const iconSize = {
821 xs: 12,
822 sm: 16,
823 md: 18,
824 lg: 24,
825 xl: 28,
826 '2xs': 8,
827 '2xl': 32,
828 '3xl': 40,
829 }[iconSizeShorthand]
830
831 /*
832 * Goal here is to match rendered text size so that different size icons
833 * don't increase button size
834 */
835 const iconContainerSize = {
836 large: 20,
837 small: 17,
838 tiny: 15,
839 }[buttonSize || 'small']
840
841 /*
842 * The icon needs to be closer to the edge of the button than the text. Therefore
843 * we make the gap slightly too large, and then pull in the sides using negative margins.
844 */
845 let iconNegativeMargin = 0
846
847 if (buttonShape === 'default') {
848 iconNegativeMargin = {
849 large: -2,
850 small: -2,
851 tiny: -1,
852 }[buttonSize || 'small']
853 }
854
855 return {
856 iconSize,
857 iconContainerSize,
858 iconNegativeMargin,
859 }
860 }, [buttonSize, buttonShape, size])
861
862 return (
863 <View
864 style={[
865 a.z_20,
866 {
867 width: size === '2xs' ? 10 : iconContainerSize,
868 height: iconContainerSize,
869 marginLeft: iconNegativeMargin,
870 marginRight: iconNegativeMargin,
871 },
872 ]}>
873 <View
874 style={[
875 a.absolute,
876 {
877 width: iconSize,
878 height: iconSize,
879 top: '50%',
880 left: '50%',
881 transform: [
882 {
883 translateX: (iconSize / 2) * -1,
884 },
885 {
886 translateY: (iconSize / 2) * -1,
887 },
888 ],
889 },
890 ]}>
891 <Comp
892 width={iconSize}
893 style={[
894 {
895 color: textStyles.color,
896 pointerEvents: 'none',
897 },
898 ]}
899 />
900 </View>
901 </View>
902 )
903}
904
905export type StackedButtonProps = Omit<
906 ButtonProps,
907 keyof VariantProps | 'children'
908> &
909 Pick<VariantProps, 'color'> & {
910 children: React.ReactNode
911 icon: React.ComponentType<SVGIconProps>
912 }
913
914export function StackedButton({children, ...props}: StackedButtonProps) {
915 return (
916 <Button
917 {...props}
918 size="tiny"
919 style={[
920 a.flex_col,
921 {
922 height: 72,
923 paddingHorizontal: 16,
924 borderRadius: 20,
925 gap: 4,
926 },
927 props.style,
928 ]}>
929 <StackedButtonInnerText icon={props.icon}>
930 {children}
931 </StackedButtonInnerText>
932 </Button>
933 )
934}
935
936function StackedButtonInnerText({
937 children,
938 icon: Icon,
939}: Pick<StackedButtonProps, 'icon' | 'children'>) {
940 const textStyles = useSharedButtonTextStyles()
941 return (
942 <>
943 <Icon width={24} fill={textStyles.color} />
944 <ButtonText>{children}</ButtonText>
945 </>
946 )
947}