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