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 Text,
11 TextProps,
12 TextStyle,
13 View,
14 ViewStyle,
15} from 'react-native'
16import {LinearGradient} from 'expo-linear-gradient'
17
18import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf'
19import {Props as SVGIconProps} from '#/components/icons/common'
20import {normalizeTextStyles} from '#/components/Typography'
21
22export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient'
23export type ButtonColor =
24 | 'primary'
25 | 'secondary'
26 | 'secondary_inverted'
27 | 'negative'
28 | 'gradient_sky'
29 | 'gradient_midnight'
30 | 'gradient_sunrise'
31 | 'gradient_sunset'
32 | 'gradient_nordic'
33 | 'gradient_bonfire'
34export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | '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 }
91
92export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
93
94const Context = React.createContext<VariantProps & ButtonState>({
95 hovered: false,
96 focused: false,
97 pressed: false,
98 disabled: false,
99})
100
101export function useButtonContext() {
102 return React.useContext(Context)
103}
104
105export const Button = React.forwardRef<View, ButtonProps>(
106 (
107 {
108 children,
109 variant,
110 color,
111 size,
112 shape = 'default',
113 label,
114 disabled = false,
115 style,
116 hoverStyle: hoverStyleProp,
117 ...rest
118 },
119 ref,
120 ) => {
121 const t = useTheme()
122 const [state, setState] = React.useState({
123 pressed: false,
124 hovered: false,
125 focused: false,
126 })
127
128 const onPressInOuter = rest.onPressIn
129 const onPressIn = React.useCallback(
130 (e: GestureResponderEvent) => {
131 setState(s => ({
132 ...s,
133 pressed: true,
134 }))
135 onPressInOuter?.(e)
136 },
137 [setState, onPressInOuter],
138 )
139 const onPressOutOuter = rest.onPressOut
140 const onPressOut = React.useCallback(
141 (e: GestureResponderEvent) => {
142 setState(s => ({
143 ...s,
144 pressed: false,
145 }))
146 onPressOutOuter?.(e)
147 },
148 [setState, onPressOutOuter],
149 )
150 const onHoverInOuter = rest.onHoverIn
151 const onHoverIn = React.useCallback(
152 (e: MouseEvent) => {
153 setState(s => ({
154 ...s,
155 hovered: true,
156 }))
157 onHoverInOuter?.(e)
158 },
159 [setState, onHoverInOuter],
160 )
161 const onHoverOutOuter = rest.onHoverOut
162 const onHoverOut = React.useCallback(
163 (e: MouseEvent) => {
164 setState(s => ({
165 ...s,
166 hovered: false,
167 }))
168 onHoverOutOuter?.(e)
169 },
170 [setState, onHoverOutOuter],
171 )
172 const onFocus = React.useCallback(() => {
173 setState(s => ({
174 ...s,
175 focused: true,
176 }))
177 }, [setState])
178 const onBlur = React.useCallback(() => {
179 setState(s => ({
180 ...s,
181 focused: false,
182 }))
183 }, [setState])
184
185 const {baseStyles, hoverStyles} = React.useMemo(() => {
186 const baseStyles: ViewStyle[] = []
187 const hoverStyles: ViewStyle[] = []
188
189 if (color === 'primary') {
190 if (variant === 'solid') {
191 if (!disabled) {
192 baseStyles.push({
193 backgroundColor: t.palette.primary_500,
194 })
195 hoverStyles.push({
196 backgroundColor: t.palette.primary_600,
197 })
198 } else {
199 baseStyles.push({
200 backgroundColor: select(t.name, {
201 light: t.palette.primary_700,
202 dim: t.palette.primary_300,
203 dark: t.palette.primary_300,
204 }),
205 })
206 }
207 } else if (variant === 'outline') {
208 baseStyles.push(a.border, t.atoms.bg, {
209 borderWidth: 1,
210 })
211
212 if (!disabled) {
213 baseStyles.push(a.border, {
214 borderColor: t.palette.primary_500,
215 })
216 hoverStyles.push(a.border, {
217 backgroundColor: t.palette.primary_50,
218 })
219 } else {
220 baseStyles.push(a.border, {
221 borderColor: t.palette.primary_200,
222 })
223 }
224 } else if (variant === 'ghost') {
225 if (!disabled) {
226 baseStyles.push(t.atoms.bg)
227 hoverStyles.push({
228 backgroundColor: t.palette.primary_100,
229 })
230 }
231 }
232 } else if (color === 'secondary') {
233 if (variant === 'solid') {
234 if (!disabled) {
235 baseStyles.push(t.atoms.bg_contrast_25)
236 hoverStyles.push(t.atoms.bg_contrast_50)
237 } else {
238 baseStyles.push(t.atoms.bg_contrast_100)
239 }
240 } else if (variant === 'outline') {
241 baseStyles.push(a.border, t.atoms.bg, {
242 borderWidth: 1,
243 })
244
245 if (!disabled) {
246 baseStyles.push(a.border, {
247 borderColor: t.palette.contrast_300,
248 })
249 hoverStyles.push(t.atoms.bg_contrast_50)
250 } else {
251 baseStyles.push(a.border, {
252 borderColor: t.palette.contrast_200,
253 })
254 }
255 } else if (variant === 'ghost') {
256 if (!disabled) {
257 baseStyles.push(t.atoms.bg)
258 hoverStyles.push({
259 backgroundColor: t.palette.contrast_25,
260 })
261 }
262 }
263 } else if (color === 'secondary_inverted') {
264 if (variant === 'solid') {
265 if (!disabled) {
266 baseStyles.push({
267 backgroundColor: t.palette.contrast_900,
268 })
269 hoverStyles.push({
270 backgroundColor: t.palette.contrast_950,
271 })
272 } else {
273 baseStyles.push({
274 backgroundColor: t.palette.contrast_600,
275 })
276 }
277 } else if (variant === 'outline') {
278 baseStyles.push(a.border, t.atoms.bg, {
279 borderWidth: 1,
280 })
281
282 if (!disabled) {
283 baseStyles.push(a.border, {
284 borderColor: t.palette.contrast_300,
285 })
286 hoverStyles.push(t.atoms.bg_contrast_50)
287 } else {
288 baseStyles.push(a.border, {
289 borderColor: t.palette.contrast_200,
290 })
291 }
292 } else if (variant === 'ghost') {
293 if (!disabled) {
294 baseStyles.push(t.atoms.bg)
295 hoverStyles.push({
296 backgroundColor: t.palette.contrast_25,
297 })
298 }
299 }
300 } else if (color === 'negative') {
301 if (variant === 'solid') {
302 if (!disabled) {
303 baseStyles.push({
304 backgroundColor: t.palette.negative_500,
305 })
306 hoverStyles.push({
307 backgroundColor: t.palette.negative_600,
308 })
309 } else {
310 baseStyles.push({
311 backgroundColor: select(t.name, {
312 light: t.palette.negative_700,
313 dim: t.palette.negative_300,
314 dark: t.palette.negative_300,
315 }),
316 })
317 }
318 } else if (variant === 'outline') {
319 baseStyles.push(a.border, t.atoms.bg, {
320 borderWidth: 1,
321 })
322
323 if (!disabled) {
324 baseStyles.push(a.border, {
325 borderColor: t.palette.negative_500,
326 })
327 hoverStyles.push(a.border, {
328 backgroundColor: t.palette.negative_50,
329 })
330 } else {
331 baseStyles.push(a.border, {
332 borderColor: t.palette.negative_200,
333 })
334 }
335 } else if (variant === 'ghost') {
336 if (!disabled) {
337 baseStyles.push(t.atoms.bg)
338 hoverStyles.push({
339 backgroundColor: t.palette.negative_100,
340 })
341 }
342 }
343 }
344
345 if (shape === 'default') {
346 if (size === 'large') {
347 baseStyles.push(
348 {paddingVertical: 15},
349 a.px_2xl,
350 a.rounded_sm,
351 a.gap_md,
352 )
353 } else if (size === 'medium') {
354 baseStyles.push(
355 {paddingVertical: 12},
356 a.px_2xl,
357 a.rounded_sm,
358 a.gap_md,
359 )
360 } else if (size === 'small') {
361 baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
362 } else if (size === 'xsmall') {
363 baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
364 } else if (size === 'tiny') {
365 baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
366 }
367 } else if (shape === 'round' || shape === 'square') {
368 if (size === 'large') {
369 if (shape === 'round') {
370 baseStyles.push({height: 54, width: 54})
371 } else {
372 baseStyles.push({height: 50, width: 50})
373 }
374 } else if (size === 'small') {
375 baseStyles.push({height: 34, width: 34})
376 } else if (size === 'xsmall') {
377 baseStyles.push({height: 28, width: 28})
378 } else if (size === 'tiny') {
379 baseStyles.push({height: 20, width: 20})
380 }
381
382 if (shape === 'round') {
383 baseStyles.push(a.rounded_full)
384 } else if (shape === 'square') {
385 if (size === 'tiny') {
386 baseStyles.push(a.rounded_xs)
387 } else {
388 baseStyles.push(a.rounded_sm)
389 }
390 }
391 }
392
393 return {
394 baseStyles,
395 hoverStyles,
396 }
397 }, [t, variant, color, size, shape, disabled])
398
399 const {gradientColors, gradientHoverColors, gradientLocations} =
400 React.useMemo(() => {
401 const colors: string[] = []
402 const hoverColors: string[] = []
403 const locations: number[] = []
404 const gradient = {
405 primary: tokens.gradients.sky,
406 secondary: tokens.gradients.sky,
407 secondary_inverted: tokens.gradients.sky,
408 negative: tokens.gradients.sky,
409 gradient_sky: tokens.gradients.sky,
410 gradient_midnight: tokens.gradients.midnight,
411 gradient_sunrise: tokens.gradients.sunrise,
412 gradient_sunset: tokens.gradients.sunset,
413 gradient_nordic: tokens.gradients.nordic,
414 gradient_bonfire: tokens.gradients.bonfire,
415 }[color || 'primary']
416
417 if (variant === 'gradient') {
418 colors.push(...gradient.values.map(([_, color]) => color))
419 hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
420 locations.push(...gradient.values.map(([location, _]) => location))
421 }
422
423 return {
424 gradientColors: colors,
425 gradientHoverColors: hoverColors,
426 gradientLocations: locations,
427 }
428 }, [variant, color])
429
430 const context = React.useMemo<ButtonContext>(
431 () => ({
432 ...state,
433 variant,
434 color,
435 size,
436 disabled: disabled || false,
437 }),
438 [state, variant, color, size, disabled],
439 )
440
441 const flattenedBaseStyles = flatten(baseStyles)
442
443 return (
444 <Pressable
445 role="button"
446 accessibilityHint={undefined} // optional
447 {...rest}
448 ref={ref}
449 aria-label={label}
450 aria-pressed={state.pressed}
451 accessibilityLabel={label}
452 disabled={disabled || false}
453 accessibilityState={{
454 disabled: disabled || false,
455 }}
456 style={[
457 a.flex_row,
458 a.align_center,
459 a.justify_center,
460 flattenedBaseStyles,
461 flatten(style),
462 ...(state.hovered || state.pressed
463 ? [hoverStyles, flatten(hoverStyleProp)]
464 : []),
465 ]}
466 onPressIn={onPressIn}
467 onPressOut={onPressOut}
468 onHoverIn={onHoverIn}
469 onHoverOut={onHoverOut}
470 onFocus={onFocus}
471 onBlur={onBlur}>
472 {variant === 'gradient' && (
473 <View
474 style={[
475 a.absolute,
476 a.inset_0,
477 a.overflow_hidden,
478 {borderRadius: flattenedBaseStyles.borderRadius},
479 ]}>
480 <LinearGradient
481 colors={
482 state.hovered || state.pressed
483 ? gradientHoverColors
484 : gradientColors
485 }
486 locations={gradientLocations}
487 start={{x: 0, y: 0}}
488 end={{x: 1, y: 1}}
489 style={[a.absolute, a.inset_0]}
490 />
491 </View>
492 )}
493 <Context.Provider value={context}>
494 {typeof children === 'function' ? children(context) : children}
495 </Context.Provider>
496 </Pressable>
497 )
498 },
499)
500Button.displayName = 'Button'
501
502export function useSharedButtonTextStyles() {
503 const t = useTheme()
504 const {color, variant, disabled, size} = useButtonContext()
505 return React.useMemo(() => {
506 const baseStyles: TextStyle[] = []
507
508 if (color === 'primary') {
509 if (variant === 'solid') {
510 if (!disabled) {
511 baseStyles.push({color: t.palette.white})
512 } else {
513 baseStyles.push({color: t.palette.white, opacity: 0.5})
514 }
515 } else if (variant === 'outline') {
516 if (!disabled) {
517 baseStyles.push({
518 color: t.palette.primary_600,
519 })
520 } else {
521 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
522 }
523 } else if (variant === 'ghost') {
524 if (!disabled) {
525 baseStyles.push({color: t.palette.primary_600})
526 } else {
527 baseStyles.push({color: t.palette.primary_600, opacity: 0.5})
528 }
529 }
530 } else if (color === 'secondary') {
531 if (variant === 'solid' || variant === 'gradient') {
532 if (!disabled) {
533 baseStyles.push({
534 color: t.palette.contrast_700,
535 })
536 } else {
537 baseStyles.push({
538 color: t.palette.contrast_400,
539 })
540 }
541 } else if (variant === 'outline') {
542 if (!disabled) {
543 baseStyles.push({
544 color: t.palette.contrast_600,
545 })
546 } else {
547 baseStyles.push({
548 color: t.palette.contrast_300,
549 })
550 }
551 } else if (variant === 'ghost') {
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 }
562 } else if (color === 'secondary_inverted') {
563 if (variant === 'solid' || variant === 'gradient') {
564 if (!disabled) {
565 baseStyles.push({
566 color: t.palette.contrast_100,
567 })
568 } else {
569 baseStyles.push({
570 color: t.palette.contrast_400,
571 })
572 }
573 } else if (variant === 'outline') {
574 if (!disabled) {
575 baseStyles.push({
576 color: t.palette.contrast_600,
577 })
578 } else {
579 baseStyles.push({
580 color: t.palette.contrast_300,
581 })
582 }
583 } else if (variant === 'ghost') {
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 }
594 } else if (color === 'negative') {
595 if (variant === 'solid' || variant === 'gradient') {
596 if (!disabled) {
597 baseStyles.push({color: t.palette.white})
598 } else {
599 baseStyles.push({color: t.palette.white, opacity: 0.5})
600 }
601 } else if (variant === 'outline') {
602 if (!disabled) {
603 baseStyles.push({color: t.palette.negative_400})
604 } else {
605 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
606 }
607 } else if (variant === 'ghost') {
608 if (!disabled) {
609 baseStyles.push({color: t.palette.negative_400})
610 } else {
611 baseStyles.push({color: t.palette.negative_400, opacity: 0.5})
612 }
613 }
614 } else {
615 if (!disabled) {
616 baseStyles.push({color: t.palette.white})
617 } else {
618 baseStyles.push({color: t.palette.white, opacity: 0.5})
619 }
620 }
621
622 if (size === 'large') {
623 baseStyles.push(a.text_md, android({paddingBottom: 1}))
624 } else if (size === 'tiny') {
625 baseStyles.push(a.text_xs, android({paddingBottom: 1}))
626 } else {
627 baseStyles.push(a.text_sm, android({paddingBottom: 1}))
628 }
629
630 return StyleSheet.flatten(baseStyles)
631 }, [t, variant, color, size, disabled])
632}
633
634export function ButtonText({children, style, ...rest}: ButtonTextProps) {
635 const textStyles = useSharedButtonTextStyles()
636
637 return (
638 <Text
639 {...rest}
640 style={normalizeTextStyles([
641 a.font_bold,
642 a.text_center,
643 textStyles,
644 style,
645 ])}>
646 {children}
647 </Text>
648 )
649}
650
651export function ButtonIcon({
652 icon: Comp,
653 position,
654 size: iconSize,
655}: {
656 icon: React.ComponentType<SVGIconProps>
657 position?: 'left' | 'right'
658 size?: SVGIconProps['size']
659}) {
660 const {size, disabled} = useButtonContext()
661 const textStyles = useSharedButtonTextStyles()
662
663 return (
664 <View
665 style={[
666 a.z_20,
667 {
668 opacity: disabled ? 0.7 : 1,
669 marginLeft: position === 'left' ? -2 : 0,
670 marginRight: position === 'right' ? -2 : 0,
671 },
672 ]}>
673 <Comp
674 size={
675 iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
676 }
677 style={[{color: textStyles.color, pointerEvents: 'none'}]}
678 />
679 </View>
680 )
681}