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