mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {
3 AccessibilityProps,
4 GestureResponderEvent,
5 MouseEvent,
6 Pressable,
7 PressableProps,
8 StyleProp,
9 StyleSheet,
10 TextProps,
11 TextStyle,
12 View,
13 ViewStyle,
14} from 'react-native'
15import {LinearGradient} from 'expo-linear-gradient'
16
17import {atoms as a, flatten, select, tokens, useTheme} from '#/alf'
18import {Props as SVGIconProps} from '#/components/icons/common'
19import {Text} from '#/components/Typography'
20
21export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
22export type ButtonColor =
23 | 'primary'
24 | 'secondary'
25 | 'secondary_inverted'
26 | 'negative'
27 | 'gradient_primary'
28 | 'gradient_sky'
29 | 'gradient_midnight'
30 | 'gradient_sunrise'
31 | 'gradient_sunset'
32 | 'gradient_nordic'
33 | 'gradient_bonfire'
34export type ButtonSize = 'tiny' | 'small' | 'large'
35export type ButtonShape = 'round' | 'square' | 'default'
36export type VariantProps = {
37 /**
38 * The style variation of the button
39 */
40 variant?: ButtonVariant
41 /**
42 * The color of the button
43 */
44 color?: ButtonColor
45 /**
46 * The size of the button
47 */
48 size?: ButtonSize
49 /**
50 * The shape of the button
51 */
52 shape?: ButtonShape
53}
54
55export type ButtonState = {
56 hovered: boolean
57 focused: boolean
58 pressed: boolean
59 disabled: boolean
60}
61
62export type ButtonContext = VariantProps & ButtonState
63
64type NonTextElements =
65 | React.ReactElement
66 | Iterable<React.ReactElement | null | undefined | boolean>
67
68export type ButtonProps = Pick<
69 PressableProps,
70 | 'disabled'
71 | 'onPress'
72 | 'testID'
73 | 'onLongPress'
74 | 'hitSlop'
75 | 'onHoverIn'
76 | 'onHoverOut'
77 | 'onPressIn'
78 | 'onPressOut'
79> &
80 AccessibilityProps &
81 VariantProps & {
82 testID?: string
83 /**
84 * For a11y, try to make this descriptive and clear
85 */
86 label: string
87 style?: StyleProp<ViewStyle>
88 hoverStyle?: StyleProp<ViewStyle>
89 children: NonTextElements | ((context: ButtonContext) => NonTextElements)
90 PressableComponent?: React.ComponentType<PressableProps>
91 }
92
93export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
94
95const Context = React.createContext<VariantProps & ButtonState>({
96 hovered: false,
97 focused: false,
98 pressed: false,
99 disabled: false,
100})
101
102export function useButtonContext() {
103 return React.useContext(Context)
104}
105
106export const Button = React.forwardRef<View, ButtonProps>(
107 (
108 {
109 children,
110 variant,
111 color,
112 size,
113 shape = 'default',
114 label,
115 disabled = false,
116 style,
117 hoverStyle: hoverStyleProp,
118 PressableComponent = Pressable,
119 ...rest
120 },
121 ref,
122 ) => {
123 const t = useTheme()
124 const [state, setState] = React.useState({
125 pressed: false,
126 hovered: false,
127 focused: false,
128 })
129
130 const onPressInOuter = rest.onPressIn
131 const onPressIn = React.useCallback(
132 (e: GestureResponderEvent) => {
133 setState(s => ({
134 ...s,
135 pressed: true,
136 }))
137 onPressInOuter?.(e)
138 },
139 [setState, onPressInOuter],
140 )
141 const onPressOutOuter = rest.onPressOut
142 const onPressOut = React.useCallback(
143 (e: GestureResponderEvent) => {
144 setState(s => ({
145 ...s,
146 pressed: false,
147 }))
148 onPressOutOuter?.(e)
149 },
150 [setState, onPressOutOuter],
151 )
152 const onHoverInOuter = rest.onHoverIn
153 const onHoverIn = React.useCallback(
154 (e: MouseEvent) => {
155 setState(s => ({
156 ...s,
157 hovered: true,
158 }))
159 onHoverInOuter?.(e)
160 },
161 [setState, onHoverInOuter],
162 )
163 const onHoverOutOuter = rest.onHoverOut
164 const onHoverOut = React.useCallback(
165 (e: MouseEvent) => {
166 setState(s => ({
167 ...s,
168 hovered: false,
169 }))
170 onHoverOutOuter?.(e)
171 },
172 [setState, onHoverOutOuter],
173 )
174 const onFocus = React.useCallback(() => {
175 setState(s => ({
176 ...s,
177 focused: true,
178 }))
179 }, [setState])
180 const onBlur = React.useCallback(() => {
181 setState(s => ({
182 ...s,
183 focused: false,
184 }))
185 }, [setState])
186
187 const {baseStyles, hoverStyles} = React.useMemo(() => {
188 const baseStyles: ViewStyle[] = []
189 const hoverStyles: ViewStyle[] = []
190
191 if (color === 'primary') {
192 if (variant === 'solid') {
193 if (!disabled) {
194 baseStyles.push({
195 backgroundColor: t.palette.primary_500,
196 })
197 hoverStyles.push({
198 backgroundColor: t.palette.primary_600,
199 })
200 } else {
201 baseStyles.push({
202 backgroundColor: select(t.name, {
203 light: t.palette.primary_700,
204 dim: t.palette.primary_300,
205 dark: t.palette.primary_300,
206 }),
207 })
208 }
209 } else if (variant === 'outline') {
210 baseStyles.push(a.border, t.atoms.bg, {
211 borderWidth: 1,
212 })
213
214 if (!disabled) {
215 baseStyles.push(a.border, {
216 borderColor: t.palette.primary_500,
217 })
218 hoverStyles.push(a.border, {
219 backgroundColor: t.palette.primary_50,
220 })
221 } else {
222 baseStyles.push(a.border, {
223 borderColor: t.palette.primary_200,
224 })
225 }
226 } else if (variant === 'ghost') {
227 if (!disabled) {
228 baseStyles.push(t.atoms.bg)
229 hoverStyles.push({
230 backgroundColor: t.palette.primary_100,
231 })
232 }
233 }
234 } else if (color === 'secondary') {
235 if (variant === 'solid') {
236 if (!disabled) {
237 baseStyles.push(t.atoms.bg_contrast_25)
238 hoverStyles.push(t.atoms.bg_contrast_50)
239 } else {
240 baseStyles.push(t.atoms.bg_contrast_100)
241 }
242 } else if (variant === 'outline') {
243 baseStyles.push(a.border, t.atoms.bg, {
244 borderWidth: 1,
245 })
246
247 if (!disabled) {
248 baseStyles.push(a.border, {
249 borderColor: t.palette.contrast_300,
250 })
251 hoverStyles.push(t.atoms.bg_contrast_50)
252 } else {
253 baseStyles.push(a.border, {
254 borderColor: t.palette.contrast_200,
255 })
256 }
257 } else if (variant === 'ghost') {
258 if (!disabled) {
259 baseStyles.push(t.atoms.bg)
260 hoverStyles.push({
261 backgroundColor: t.palette.contrast_25,
262 })
263 }
264 }
265 } else if (color === 'secondary_inverted') {
266 if (variant === 'solid') {
267 if (!disabled) {
268 baseStyles.push({
269 backgroundColor: t.palette.contrast_900,
270 })
271 hoverStyles.push({
272 backgroundColor: t.palette.contrast_950,
273 })
274 } else {
275 baseStyles.push({
276 backgroundColor: t.palette.contrast_600,
277 })
278 }
279 } else if (variant === 'outline') {
280 baseStyles.push(a.border, t.atoms.bg, {
281 borderWidth: 1,
282 })
283
284 if (!disabled) {
285 baseStyles.push(a.border, {
286 borderColor: t.palette.contrast_300,
287 })
288 hoverStyles.push(t.atoms.bg_contrast_50)
289 } else {
290 baseStyles.push(a.border, {
291 borderColor: t.palette.contrast_200,
292 })
293 }
294 } else if (variant === 'ghost') {
295 if (!disabled) {
296 baseStyles.push(t.atoms.bg)
297 hoverStyles.push({
298 backgroundColor: t.palette.contrast_25,
299 })
300 }
301 }
302 } else if (color === 'negative') {
303 if (variant === 'solid') {
304 if (!disabled) {
305 baseStyles.push({
306 backgroundColor: t.palette.negative_500,
307 })
308 hoverStyles.push({
309 backgroundColor: t.palette.negative_600,
310 })
311 } else {
312 baseStyles.push({
313 backgroundColor: select(t.name, {
314 light: t.palette.negative_700,
315 dim: t.palette.negative_300,
316 dark: t.palette.negative_300,
317 }),
318 })
319 }
320 } else if (variant === 'outline') {
321 baseStyles.push(a.border, t.atoms.bg, {
322 borderWidth: 1,
323 })
324
325 if (!disabled) {
326 baseStyles.push(a.border, {
327 borderColor: t.palette.negative_500,
328 })
329 hoverStyles.push(a.border, {
330 backgroundColor: t.palette.negative_50,
331 })
332 } else {
333 baseStyles.push(a.border, {
334 borderColor: t.palette.negative_200,
335 })
336 }
337 } else if (variant === 'ghost') {
338 if (!disabled) {
339 baseStyles.push(t.atoms.bg)
340 hoverStyles.push({
341 backgroundColor: t.palette.negative_100,
342 })
343 }
344 }
345 }
346
347 if (shape === 'default') {
348 if (size === 'large') {
349 baseStyles.push({
350 paddingVertical: 13,
351 paddingHorizontal: 20,
352 borderRadius: 8,
353 gap: 8,
354 })
355 } else if (size === 'small') {
356 baseStyles.push({
357 paddingVertical: 9,
358 paddingHorizontal: 12,
359 borderRadius: 6,
360 gap: 6,
361 })
362 } else if (size === 'tiny') {
363 baseStyles.push({
364 paddingVertical: 4,
365 paddingHorizontal: 8,
366 borderRadius: 4,
367 gap: 4,
368 })
369 }
370 } else if (shape === 'round' || shape === 'square') {
371 if (size === 'large') {
372 if (shape === 'round') {
373 baseStyles.push({height: 46, width: 46})
374 } else {
375 baseStyles.push({height: 44, width: 44})
376 }
377 } else if (size === 'small') {
378 if (shape === 'round') {
379 baseStyles.push({height: 34, width: 34})
380 } else {
381 baseStyles.push({height: 34, width: 34})
382 }
383 } else if (size === 'tiny') {
384 if (shape === 'round') {
385 baseStyles.push({height: 22, width: 22})
386 } else {
387 baseStyles.push({height: 21, width: 21})
388 }
389 }
390
391 if (shape === 'round') {
392 baseStyles.push(a.rounded_full)
393 } else if (shape === 'square') {
394 if (size === 'tiny') {
395 baseStyles.push(a.rounded_xs)
396 } else {
397 baseStyles.push(a.rounded_sm)
398 }
399 }
400 }
401
402 return {
403 baseStyles,
404 hoverStyles,
405 }
406 }, [t, variant, color, size, shape, disabled])
407
408 const {gradientColors, gradientHoverColors, gradientLocations} =
409 React.useMemo(() => {
410 const colors: string[] = []
411 const hoverColors: string[] = []
412 const locations: number[] = []
413 const gradient = {
414 primary: tokens.gradients.sky,
415 secondary: tokens.gradients.sky,
416 secondary_inverted: tokens.gradients.sky,
417 negative: tokens.gradients.sky,
418 gradient_primary: tokens.gradients.primary,
419 gradient_sky: tokens.gradients.sky,
420 gradient_midnight: tokens.gradients.midnight,
421 gradient_sunrise: tokens.gradients.sunrise,
422 gradient_sunset: tokens.gradients.sunset,
423 gradient_nordic: tokens.gradients.nordic,
424 gradient_bonfire: tokens.gradients.bonfire,
425 }[color || 'primary']
426
427 if (variant === 'gradient') {
428 colors.push(...gradient.values.map(([_, color]) => color))
429 hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
430 locations.push(...gradient.values.map(([location, _]) => location))
431 }
432
433 return {
434 gradientColors: colors,
435 gradientHoverColors: hoverColors,
436 gradientLocations: locations,
437 }
438 }, [variant, color])
439
440 const context = React.useMemo<ButtonContext>(
441 () => ({
442 ...state,
443 variant,
444 color,
445 size,
446 disabled: disabled || false,
447 }),
448 [state, variant, color, size, disabled],
449 )
450
451 const flattenedBaseStyles = flatten([baseStyles, style])
452
453 return (
454 <PressableComponent
455 role="button"
456 accessibilityHint={undefined} // optional
457 {...rest}
458 // @ts-ignore - this will always be a pressable
459 ref={ref}
460 aria-label={label}
461 aria-pressed={state.pressed}
462 accessibilityLabel={label}
463 disabled={disabled || false}
464 accessibilityState={{
465 disabled: disabled || false,
466 }}
467 style={[
468 a.flex_row,
469 a.align_center,
470 a.justify_center,
471 flattenedBaseStyles,
472 ...(state.hovered || state.pressed
473 ? [hoverStyles, flatten(hoverStyleProp)]
474 : []),
475 ]}
476 onPressIn={onPressIn}
477 onPressOut={onPressOut}
478 onHoverIn={onHoverIn}
479 onHoverOut={onHoverOut}
480 onFocus={onFocus}
481 onBlur={onBlur}>
482 {variant === 'gradient' && (
483 <View
484 style={[
485 a.absolute,
486 a.inset_0,
487 a.overflow_hidden,
488 {borderRadius: flattenedBaseStyles.borderRadius},
489 ]}>
490 <LinearGradient
491 colors={
492 state.hovered || state.pressed
493 ? gradientHoverColors
494 : gradientColors
495 }
496 locations={gradientLocations}
497 start={{x: 0, y: 0}}
498 end={{x: 1, y: 1}}
499 style={[a.absolute, a.inset_0]}
500 />
501 </View>
502 )}
503 <Context.Provider value={context}>
504 {typeof children === 'function' ? children(context) : children}
505 </Context.Provider>
506 </PressableComponent>
507 )
508 },
509)
510Button.displayName = 'Button'
511
512export function useSharedButtonTextStyles() {
513 const t = useTheme()
514 const {color, variant, disabled, size} = useButtonContext()
515 return React.useMemo(() => {
516 const baseStyles: TextStyle[] = []
517
518 if (color === 'primary') {
519 if (variant === 'solid') {
520 if (!disabled) {
521 baseStyles.push({color: t.palette.white})
522 } else {
523 baseStyles.push({color: t.palette.white, opacity: 0.5})
524 }
525 } else if (variant === 'outline') {
526 if (!disabled) {
527 baseStyles.push({
528 color: t.palette.primary_600,
529 })
530 } else {
531 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
532 }
533 } else if (variant === 'ghost') {
534 if (!disabled) {
535 baseStyles.push({color: t.palette.primary_600})
536 } else {
537 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
538 }
539 }
540 } else if (color === 'secondary') {
541 if (variant === 'solid' || variant === 'gradient') {
542 if (!disabled) {
543 baseStyles.push({
544 color: t.palette.contrast_700,
545 })
546 } else {
547 baseStyles.push({
548 color: t.palette.contrast_400,
549 })
550 }
551 } else if (variant === 'outline') {
552 if (!disabled) {
553 baseStyles.push({
554 color: t.palette.contrast_600,
555 })
556 } else {
557 baseStyles.push({
558 color: t.palette.contrast_300,
559 })
560 }
561 } else if (variant === 'ghost') {
562 if (!disabled) {
563 baseStyles.push({
564 color: t.palette.contrast_600,
565 })
566 } else {
567 baseStyles.push({
568 color: t.palette.contrast_300,
569 })
570 }
571 }
572 } else if (color === 'secondary_inverted') {
573 if (variant === 'solid' || variant === 'gradient') {
574 if (!disabled) {
575 baseStyles.push({
576 color: t.palette.contrast_100,
577 })
578 } else {
579 baseStyles.push({
580 color: t.palette.contrast_400,
581 })
582 }
583 } else if (variant === 'outline') {
584 if (!disabled) {
585 baseStyles.push({
586 color: t.palette.contrast_600,
587 })
588 } else {
589 baseStyles.push({
590 color: t.palette.contrast_300,
591 })
592 }
593 } else if (variant === 'ghost') {
594 if (!disabled) {
595 baseStyles.push({
596 color: t.palette.contrast_600,
597 })
598 } else {
599 baseStyles.push({
600 color: t.palette.contrast_300,
601 })
602 }
603 }
604 } else if (color === 'negative') {
605 if (variant === 'solid' || variant === 'gradient') {
606 if (!disabled) {
607 baseStyles.push({color: t.palette.white})
608 } else {
609 baseStyles.push({color: t.palette.white, opacity: 0.5})
610 }
611 } else if (variant === 'outline') {
612 if (!disabled) {
613 baseStyles.push({color: t.palette.negative_400})
614 } else {
615 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
616 }
617 } else if (variant === 'ghost') {
618 if (!disabled) {
619 baseStyles.push({color: t.palette.negative_400})
620 } else {
621 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
622 }
623 }
624 } else {
625 if (!disabled) {
626 baseStyles.push({color: t.palette.white})
627 } else {
628 baseStyles.push({color: t.palette.white, opacity: 0.5})
629 }
630 }
631
632 if (size === 'large') {
633 baseStyles.push(a.text_md, a.leading_tight)
634 } else if (size === 'small') {
635 baseStyles.push(a.text_sm, a.leading_tight)
636 } else if (size === 'tiny') {
637 baseStyles.push(a.text_xs, a.leading_tight)
638 }
639
640 return StyleSheet.flatten(baseStyles)
641 }, [t, variant, color, size, disabled])
642}
643
644export function ButtonText({children, style, ...rest}: ButtonTextProps) {
645 const textStyles = useSharedButtonTextStyles()
646
647 return (
648 <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
649 {children}
650 </Text>
651 )
652}
653
654export function ButtonIcon({
655 icon: Comp,
656 position,
657 size,
658}: {
659 icon: React.ComponentType<SVGIconProps>
660 position?: 'left' | 'right'
661 size?: SVGIconProps['size']
662}) {
663 const {size: buttonSize, disabled} = useButtonContext()
664 const textStyles = useSharedButtonTextStyles()
665 const {iconSize, iconContainerSize} = React.useMemo(() => {
666 /**
667 * Pre-set icon sizes for different button sizes
668 */
669 const iconSizeShorthand =
670 size ??
671 (({
672 large: 'sm',
673 small: 'sm',
674 tiny: 'xs',
675 }[buttonSize || 'small'] || 'sm') as Exclude<
676 SVGIconProps['size'],
677 undefined
678 >)
679
680 /*
681 * Copied here from icons/common.tsx so we can tweak if we need to, but
682 * also so that we can calculate transforms.
683 */
684 const iconSize = {
685 xs: 12,
686 sm: 16,
687 md: 20,
688 lg: 24,
689 xl: 28,
690 '2xl': 32,
691 }[iconSizeShorthand]
692
693 /*
694 * Goal here is to match rendered text size so that different size icons
695 * don't increase button size
696 */
697 const iconContainerSize = {
698 large: 18,
699 small: 16,
700 tiny: 13,
701 }[buttonSize || 'small']
702
703 return {
704 iconSize,
705 iconContainerSize,
706 }
707 }, [buttonSize, size])
708
709 return (
710 <View
711 style={[
712 a.z_20,
713 {
714 width: iconContainerSize,
715 height: iconContainerSize,
716 opacity: disabled ? 0.7 : 1,
717 marginLeft: position === 'left' ? -2 : 0,
718 marginRight: position === 'right' ? -2 : 0,
719 },
720 ]}>
721 <View
722 style={[
723 a.absolute,
724 {
725 width: iconSize,
726 height: iconSize,
727 top: '50%',
728 left: '50%',
729 transform: [
730 {
731 translateX: (iconSize / 2) * -1,
732 },
733 {
734 translateY: (iconSize / 2) * -1,
735 },
736 ],
737 },
738 ]}>
739 <Comp
740 width={iconSize}
741 style={[
742 {
743 color: textStyles.color,
744 pointerEvents: 'none',
745 },
746 ]}
747 />
748 </View>
749 </View>
750 )
751}