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 gradientValues = React.useMemo(() => {
409 const gradient = {
410 primary: tokens.gradients.sky,
411 secondary: tokens.gradients.sky,
412 secondary_inverted: tokens.gradients.sky,
413 negative: tokens.gradients.sky,
414 gradient_primary: tokens.gradients.primary,
415 gradient_sky: tokens.gradients.sky,
416 gradient_midnight: tokens.gradients.midnight,
417 gradient_sunrise: tokens.gradients.sunrise,
418 gradient_sunset: tokens.gradients.sunset,
419 gradient_nordic: tokens.gradients.nordic,
420 gradient_bonfire: tokens.gradients.bonfire,
421 }[color || 'primary']
422
423 if (variant === 'gradient') {
424 if (gradient.values.length < 2) {
425 throw new Error(
426 'Gradient buttons must have at least two colors in the gradient',
427 )
428 }
429
430 return {
431 colors: gradient.values.map(([_, color]) => color) as [
432 string,
433 string,
434 ...string[],
435 ],
436 hoverColors: gradient.values.map(_ => gradient.hover_value) as [
437 string,
438 string,
439 ...string[],
440 ],
441 locations: gradient.values.map(([location, _]) => location) as [
442 number,
443 number,
444 ...number[],
445 ],
446 }
447 }
448 }, [variant, color])
449
450 const context = React.useMemo<ButtonContext>(
451 () => ({
452 ...state,
453 variant,
454 color,
455 size,
456 disabled: disabled || false,
457 }),
458 [state, variant, color, size, disabled],
459 )
460
461 const flattenedBaseStyles = flatten([baseStyles, style])
462
463 return (
464 <PressableComponent
465 role="button"
466 accessibilityHint={undefined} // optional
467 {...rest}
468 // @ts-ignore - this will always be a pressable
469 ref={ref}
470 aria-label={label}
471 aria-pressed={state.pressed}
472 accessibilityLabel={label}
473 disabled={disabled || false}
474 accessibilityState={{
475 disabled: disabled || false,
476 }}
477 style={[
478 a.flex_row,
479 a.align_center,
480 a.justify_center,
481 flattenedBaseStyles,
482 ...(state.hovered || state.pressed
483 ? [hoverStyles, flatten(hoverStyleProp)]
484 : []),
485 ]}
486 onPressIn={onPressIn}
487 onPressOut={onPressOut}
488 onHoverIn={onHoverIn}
489 onHoverOut={onHoverOut}
490 onFocus={onFocus}
491 onBlur={onBlur}>
492 {variant === 'gradient' && gradientValues && (
493 <View
494 style={[
495 a.absolute,
496 a.inset_0,
497 a.overflow_hidden,
498 {borderRadius: flattenedBaseStyles.borderRadius},
499 ]}>
500 <LinearGradient
501 colors={
502 state.hovered || state.pressed
503 ? gradientValues.hoverColors
504 : gradientValues.colors
505 }
506 locations={gradientValues.locations}
507 start={{x: 0, y: 0}}
508 end={{x: 1, y: 1}}
509 style={[a.absolute, a.inset_0]}
510 />
511 </View>
512 )}
513 <Context.Provider value={context}>
514 {typeof children === 'function' ? children(context) : children}
515 </Context.Provider>
516 </PressableComponent>
517 )
518 },
519)
520Button.displayName = 'Button'
521
522export function useSharedButtonTextStyles() {
523 const t = useTheme()
524 const {color, variant, disabled, size} = useButtonContext()
525 return React.useMemo(() => {
526 const baseStyles: TextStyle[] = []
527
528 if (color === 'primary') {
529 if (variant === 'solid') {
530 if (!disabled) {
531 baseStyles.push({color: t.palette.white})
532 } else {
533 baseStyles.push({color: t.palette.white, opacity: 0.5})
534 }
535 } else if (variant === 'outline') {
536 if (!disabled) {
537 baseStyles.push({
538 color: t.palette.primary_600,
539 })
540 } else {
541 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
542 }
543 } else if (variant === 'ghost') {
544 if (!disabled) {
545 baseStyles.push({color: t.palette.primary_600})
546 } else {
547 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
548 }
549 }
550 } else if (color === 'secondary') {
551 if (variant === 'solid' || variant === 'gradient') {
552 if (!disabled) {
553 baseStyles.push({
554 color: t.palette.contrast_700,
555 })
556 } else {
557 baseStyles.push({
558 color: t.palette.contrast_400,
559 })
560 }
561 } else if (variant === 'outline') {
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 } else if (variant === 'ghost') {
572 if (!disabled) {
573 baseStyles.push({
574 color: t.palette.contrast_600,
575 })
576 } else {
577 baseStyles.push({
578 color: t.palette.contrast_300,
579 })
580 }
581 }
582 } else if (color === 'secondary_inverted') {
583 if (variant === 'solid' || variant === 'gradient') {
584 if (!disabled) {
585 baseStyles.push({
586 color: t.palette.contrast_100,
587 })
588 } else {
589 baseStyles.push({
590 color: t.palette.contrast_400,
591 })
592 }
593 } else if (variant === 'outline') {
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 } else if (variant === 'ghost') {
604 if (!disabled) {
605 baseStyles.push({
606 color: t.palette.contrast_600,
607 })
608 } else {
609 baseStyles.push({
610 color: t.palette.contrast_300,
611 })
612 }
613 }
614 } else if (color === 'negative') {
615 if (variant === 'solid' || variant === 'gradient') {
616 if (!disabled) {
617 baseStyles.push({color: t.palette.white})
618 } else {
619 baseStyles.push({color: t.palette.white, opacity: 0.5})
620 }
621 } else if (variant === 'outline') {
622 if (!disabled) {
623 baseStyles.push({color: t.palette.negative_400})
624 } else {
625 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
626 }
627 } else if (variant === 'ghost') {
628 if (!disabled) {
629 baseStyles.push({color: t.palette.negative_400})
630 } else {
631 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
632 }
633 }
634 } else {
635 if (!disabled) {
636 baseStyles.push({color: t.palette.white})
637 } else {
638 baseStyles.push({color: t.palette.white, opacity: 0.5})
639 }
640 }
641
642 if (size === 'large') {
643 baseStyles.push(a.text_md, a.leading_tight)
644 } else if (size === 'small') {
645 baseStyles.push(a.text_sm, a.leading_tight)
646 } else if (size === 'tiny') {
647 baseStyles.push(a.text_xs, a.leading_tight)
648 }
649
650 return StyleSheet.flatten(baseStyles)
651 }, [t, variant, color, size, disabled])
652}
653
654export function ButtonText({children, style, ...rest}: ButtonTextProps) {
655 const textStyles = useSharedButtonTextStyles()
656
657 return (
658 <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}>
659 {children}
660 </Text>
661 )
662}
663
664export function ButtonIcon({
665 icon: Comp,
666 position,
667 size,
668}: {
669 icon: React.ComponentType<SVGIconProps>
670 position?: 'left' | 'right'
671 size?: SVGIconProps['size']
672}) {
673 const {size: buttonSize, disabled} = useButtonContext()
674 const textStyles = useSharedButtonTextStyles()
675 const {iconSize, iconContainerSize} = React.useMemo(() => {
676 /**
677 * Pre-set icon sizes for different button sizes
678 */
679 const iconSizeShorthand =
680 size ??
681 (({
682 large: 'sm',
683 small: 'sm',
684 tiny: 'xs',
685 }[buttonSize || 'small'] || 'sm') as Exclude<
686 SVGIconProps['size'],
687 undefined
688 >)
689
690 /*
691 * Copied here from icons/common.tsx so we can tweak if we need to, but
692 * also so that we can calculate transforms.
693 */
694 const iconSize = {
695 xs: 12,
696 sm: 16,
697 md: 20,
698 lg: 24,
699 xl: 28,
700 '2xl': 32,
701 }[iconSizeShorthand]
702
703 /*
704 * Goal here is to match rendered text size so that different size icons
705 * don't increase button size
706 */
707 const iconContainerSize = {
708 large: 18,
709 small: 16,
710 tiny: 13,
711 }[buttonSize || 'small']
712
713 return {
714 iconSize,
715 iconContainerSize,
716 }
717 }, [buttonSize, size])
718
719 return (
720 <View
721 style={[
722 a.z_20,
723 {
724 width: iconContainerSize,
725 height: iconContainerSize,
726 opacity: disabled ? 0.7 : 1,
727 marginLeft: position === 'left' ? -2 : 0,
728 marginRight: position === 'right' ? -2 : 0,
729 },
730 ]}>
731 <View
732 style={[
733 a.absolute,
734 {
735 width: iconSize,
736 height: iconSize,
737 top: '50%',
738 left: '50%',
739 transform: [
740 {
741 translateX: (iconSize / 2) * -1,
742 },
743 {
744 translateY: (iconSize / 2) * -1,
745 },
746 ],
747 },
748 ]}>
749 <Comp
750 width={iconSize}
751 style={[
752 {
753 color: textStyles.color,
754 pointerEvents: 'none',
755 },
756 ]}
757 />
758 </View>
759 </View>
760 )
761}