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