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