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