@liguelead/design-system 0.0.10 → 0.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/Button/Button.appearance.ts +20 -16
- package/components/Button/Button.sizes.ts +18 -17
- package/components/Button/Button.styles.ts +7 -11
- package/components/Button/Button.tsx +5 -5
- package/components/Button/Button.types.ts +3 -3
- package/components/Checkbox/Checkbox.styles.ts +84 -35
- package/components/Checkbox/Checkbox.tsx +62 -5
- package/components/Checkbox/Checkbox.types.ts +1 -0
- package/components/IconButton/IconButton.sizes.ts +6 -13
- package/components/IconButton/IconButton.tsx +5 -5
- package/components/InputOpt/InputOpt.styles.ts +75 -0
- package/components/InputOpt/InputOpt.tsx +153 -0
- package/components/InputOpt/InputOpt.types.ts +14 -0
- package/components/InputOpt/index.ts +1 -0
- package/components/InputOpt/utils/focusManagement.ts +31 -0
- package/components/InputOpt/utils/index.ts +2 -0
- package/components/InputOpt/utils/inputValidation.ts +14 -0
- package/components/LinkButton/LinkButton.size.ts +39 -0
- package/components/LinkButton/LinkButton.style.ts +45 -0
- package/components/LinkButton/LinkButton.tsx +75 -0
- package/components/LinkButton/LinkButton.types.ts +11 -0
- package/components/LinkButton/index.ts +1 -0
- package/components/RadioButton/RadioButton.inputVariants.ts +133 -0
- package/components/RadioButton/RadioButton.styles.ts +78 -0
- package/components/RadioButton/RadioButton.tsx +88 -0
- package/components/RadioButton/RadioButton.types.ts +33 -0
- package/components/RadioButton/RadioButton.variants.ts +67 -0
- package/components/RadioButton/index.ts +1 -0
- package/components/SegmentedButton/SegmentedButton.tsx +0 -6
- package/components/Select/Select.sizes.ts +10 -11
- package/components/Select/Select.states.tsx +8 -37
- package/components/Select/Select.styles.ts +53 -39
- package/components/Select/Select.tsx +30 -23
- package/components/Select/Select.types.ts +1 -2
- package/components/Text/Text.styles.ts +11 -8
- package/components/Text/Text.tsx +2 -2
- package/components/Text/Text.types.ts +3 -1
- package/components/TextField/TextField.sizes.ts +3 -9
- package/components/TextField/TextField.states.tsx +11 -11
- package/components/TextField/TextField.styles.ts +4 -0
- package/components/TextField/TextField.tsx +5 -2
- package/components/index.ts +3 -0
- package/package.json +2 -2
- package/utils/darkenOrLighen.ts +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { InputOptProps } from './InputOpt.types'
|
|
3
|
+
import {
|
|
4
|
+
OptWrapper,
|
|
5
|
+
OptBox,
|
|
6
|
+
OtpSeparator,
|
|
7
|
+
OptHelperText,
|
|
8
|
+
OtpSeparatorContainer,
|
|
9
|
+
} from './InputOpt.styles'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
isValidChar,
|
|
13
|
+
sanitizeValue,
|
|
14
|
+
createInitialState,
|
|
15
|
+
focusNext,
|
|
16
|
+
focusPrevious,
|
|
17
|
+
focusFirstEmpty
|
|
18
|
+
} from './utils'
|
|
19
|
+
|
|
20
|
+
const InputOpt: React.FC<InputOptProps> = ({
|
|
21
|
+
length = 6,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
onComplete,
|
|
25
|
+
autoFocus = true,
|
|
26
|
+
disabled = false,
|
|
27
|
+
placeholderChar = '',
|
|
28
|
+
name = 'otp',
|
|
29
|
+
className,
|
|
30
|
+
inputMode = 'numeric',
|
|
31
|
+
helperText,
|
|
32
|
+
error,
|
|
33
|
+
...rest
|
|
34
|
+
}) => {
|
|
35
|
+
const isControlled = value !== undefined
|
|
36
|
+
const [internal, setInternal] = useState<string[]>(() => createInitialState(value, length))
|
|
37
|
+
const refs = useRef<Array<HTMLInputElement | null>>([])
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!isControlled) return
|
|
41
|
+
const sanitized = sanitizeValue(value!, inputMode)
|
|
42
|
+
setInternal(createInitialState(sanitized, length))
|
|
43
|
+
}, [value, inputMode, isControlled, length])
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (autoFocus && refs.current[0]) refs.current[0].focus()
|
|
47
|
+
}, [autoFocus])
|
|
48
|
+
|
|
49
|
+
const emitChange = (next: string[]) => {
|
|
50
|
+
const joined = next.join('')
|
|
51
|
+
onChange?.(joined)
|
|
52
|
+
if (joined.length === length && !next.includes('')) onComplete?.(joined)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const updateIndex = (index: number, char: string) => {
|
|
56
|
+
if (disabled || !isValidChar(char, inputMode)) return
|
|
57
|
+
|
|
58
|
+
const next = [...internal]
|
|
59
|
+
next[index] = char
|
|
60
|
+
if (!isControlled) setInternal(next)
|
|
61
|
+
emitChange(next)
|
|
62
|
+
focusNext(refs, index, length)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
|
66
|
+
if (disabled) return
|
|
67
|
+
const { key } = e
|
|
68
|
+
|
|
69
|
+
if (key === 'Backspace') {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
const next = [...internal]
|
|
72
|
+
next[index] = ''
|
|
73
|
+
if (!isControlled) setInternal(next)
|
|
74
|
+
emitChange(next)
|
|
75
|
+
focusPrevious(refs, index)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (key === 'ArrowLeft') focusPrevious(refs, index)
|
|
80
|
+
if (key === 'ArrowRight') focusNext(refs, index, length)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
|
84
|
+
const char = e.target.value.slice(-1)
|
|
85
|
+
updateIndex(index, char)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>, index: number) => {
|
|
89
|
+
if (disabled) return
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
|
|
92
|
+
const text = e.clipboardData.getData('text').trim()
|
|
93
|
+
if (!text) return
|
|
94
|
+
|
|
95
|
+
const valid = sanitizeValue(text, inputMode)
|
|
96
|
+
const next = [...internal]
|
|
97
|
+
|
|
98
|
+
for (let j = 0; j < valid.length && index + j < length; j++) {
|
|
99
|
+
next[index + j] = valid[j]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isControlled) setInternal(next)
|
|
103
|
+
emitChange(next)
|
|
104
|
+
focusFirstEmpty(refs, next, length)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
<OptWrapper
|
|
110
|
+
className={`${className || ''} ${error ? 'error' : ''}`.trim()}
|
|
111
|
+
aria-disabled={disabled}
|
|
112
|
+
aria-invalid={!!error}
|
|
113
|
+
role="group"
|
|
114
|
+
data-error={!!error}
|
|
115
|
+
>
|
|
116
|
+
{Array.from({ length }).map((_, i) => (
|
|
117
|
+
<React.Fragment key={i}>
|
|
118
|
+
<OptBox
|
|
119
|
+
ref={el => (refs.current[i] = el)}
|
|
120
|
+
$disabled={disabled}
|
|
121
|
+
$error={!!error}
|
|
122
|
+
value={internal[i] || ''}
|
|
123
|
+
placeholder={placeholderChar}
|
|
124
|
+
onKeyDown={e => handleKeyDown(e, i)}
|
|
125
|
+
onPaste={e => handlePaste(e, i)}
|
|
126
|
+
onChange={e => handleChange(e, i)}
|
|
127
|
+
{...rest}
|
|
128
|
+
inputMode={inputMode}
|
|
129
|
+
pattern={inputMode === 'numeric' ? '[0-9]*' : undefined}
|
|
130
|
+
autoComplete="one-time-code"
|
|
131
|
+
aria-label={`Dígito ${i + 1} de ${length}`}
|
|
132
|
+
disabled={disabled}
|
|
133
|
+
maxLength={1}
|
|
134
|
+
/>
|
|
135
|
+
{(i + 1) % 2 === 0 && i < length - 1 && (
|
|
136
|
+
<OtpSeparatorContainer>
|
|
137
|
+
<OtpSeparator>-</OtpSeparator>
|
|
138
|
+
</OtpSeparatorContainer>
|
|
139
|
+
)}
|
|
140
|
+
</React.Fragment>
|
|
141
|
+
))}
|
|
142
|
+
</OptWrapper>
|
|
143
|
+
|
|
144
|
+
{helperText && (
|
|
145
|
+
<OptHelperText $error={!!error} aria-live="polite">
|
|
146
|
+
{helperText}
|
|
147
|
+
</OptHelperText>
|
|
148
|
+
)}
|
|
149
|
+
</>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export default InputOpt
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface InputOptProps {
|
|
2
|
+
length?: number;
|
|
3
|
+
value?: string;
|
|
4
|
+
onChange?: (code: string) => void;
|
|
5
|
+
onComplete?: (code: string) => void;
|
|
6
|
+
autoFocus?: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
placeholderChar?: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
inputMode?: 'numeric' | 'text';
|
|
12
|
+
helperText?: string;
|
|
13
|
+
error?: boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './InputOpt'
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const focusNext = (
|
|
2
|
+
refs: React.MutableRefObject<Array<HTMLInputElement | null>>,
|
|
3
|
+
currentIndex: number,
|
|
4
|
+
length: number
|
|
5
|
+
) => {
|
|
6
|
+
if (currentIndex < length - 1) {
|
|
7
|
+
refs.current[currentIndex + 1]?.focus()
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const focusPrevious = (
|
|
12
|
+
refs: React.MutableRefObject<Array<HTMLInputElement | null>>,
|
|
13
|
+
currentIndex: number
|
|
14
|
+
) => {
|
|
15
|
+
if (currentIndex > 0) {
|
|
16
|
+
refs.current[currentIndex - 1]?.focus()
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const focusFirstEmpty = (
|
|
21
|
+
refs: React.MutableRefObject<Array<HTMLInputElement | null>>,
|
|
22
|
+
values: string[],
|
|
23
|
+
length: number
|
|
24
|
+
) => {
|
|
25
|
+
const firstEmpty = values.findIndex(c => c === '')
|
|
26
|
+
if (firstEmpty >= 0) {
|
|
27
|
+
refs.current[firstEmpty]?.focus()
|
|
28
|
+
} else {
|
|
29
|
+
refs.current[length - 1]?.blur()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const isValidChar = (char: string, inputMode: 'numeric' | 'text'): boolean => {
|
|
2
|
+
return inputMode === 'numeric' ? /^\d$/.test(char) : /^[0-9a-zA-Z]$/.test(char)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export const sanitizeValue = (value: string, inputMode: 'numeric' | 'text'): string => {
|
|
6
|
+
return inputMode === 'numeric'
|
|
7
|
+
? value.replace(/[^0-9]/g, '')
|
|
8
|
+
: value.replace(/[^0-9a-zA-Z]/g, '')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createInitialState = (value: string | undefined, length: number): string[] => {
|
|
12
|
+
const chars = (value || '').split('').slice(0, length)
|
|
13
|
+
return [...chars, ...Array(Math.max(0, length - chars.length)).fill('')]
|
|
14
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fontSize,
|
|
3
|
+
fontWeight,
|
|
4
|
+
lineHeight,
|
|
5
|
+
spacing,
|
|
6
|
+
radius
|
|
7
|
+
} from '@liguelead/foundation'
|
|
8
|
+
import { ButtonSizeTypes } from '../Button/Button.types'
|
|
9
|
+
|
|
10
|
+
export const LinkButtonSizes = (size: ButtonSizeTypes) => {
|
|
11
|
+
const sizes = {
|
|
12
|
+
sm: `
|
|
13
|
+
padding: ${spacing.spacing0}px ${spacing.spacing4}px;
|
|
14
|
+
font-size: ${fontSize.fontSize12}px;
|
|
15
|
+
font-weight: ${fontWeight.fontWeight500};
|
|
16
|
+
line-height: ${lineHeight.lineHeight22}px;
|
|
17
|
+
gap: ${spacing.spacing8}px;
|
|
18
|
+
border-radius: ${radius.radius4}px;
|
|
19
|
+
`,
|
|
20
|
+
md: `
|
|
21
|
+
padding: ${spacing.spacing0}px ${spacing.spacing4}px;
|
|
22
|
+
font-size: ${fontSize.fontSize14}px;
|
|
23
|
+
font-weight: ${fontWeight.fontWeight500};
|
|
24
|
+
line-height: ${lineHeight.lineHeight22}px;
|
|
25
|
+
gap: ${spacing.spacing8}px;
|
|
26
|
+
border-radius: ${radius.radius4}px;
|
|
27
|
+
`,
|
|
28
|
+
lg: `
|
|
29
|
+
padding: ${spacing.spacing0}px ${spacing.spacing4}px;
|
|
30
|
+
font-size: ${fontSize.fontSize16}px;
|
|
31
|
+
font-weight: ${fontWeight.fontWeight500};
|
|
32
|
+
line-height: ${lineHeight.lineHeight24}px;
|
|
33
|
+
gap: ${spacing.spacing8}px;
|
|
34
|
+
border-radius: ${radius.radius4}px;
|
|
35
|
+
`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return sizes[size]
|
|
39
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import styled from 'styled-components'
|
|
2
|
+
import { parseColor } from '../../utils'
|
|
3
|
+
import { StyledButtonProps } from '../Button/Button.styles'
|
|
4
|
+
import { shadow } from '@liguelead/foundation'
|
|
5
|
+
|
|
6
|
+
export const LinkAnchor = styled.a`
|
|
7
|
+
text-decoration: none;
|
|
8
|
+
display: inline-flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
|
|
11
|
+
color: ${({ theme }) => parseColor(theme.colors.primary)};
|
|
12
|
+
|
|
13
|
+
&:hover {
|
|
14
|
+
text-decoration: underline;
|
|
15
|
+
background-color: transparent;
|
|
16
|
+
color: ${({ theme }) => parseColor(theme.colors.primaryDark)};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
&:focus {
|
|
20
|
+
outline: 2px solid ${({ theme }) => parseColor(theme.colors.primaryLight)};
|
|
21
|
+
outline-offset: 2px;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&[aria-disabled='true'] {
|
|
25
|
+
opacity: .5;
|
|
26
|
+
pointer-events: none;
|
|
27
|
+
}
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
export const StyledLinkButton = styled.button<StyledButtonProps>`
|
|
31
|
+
position: relative;
|
|
32
|
+
display: flex;
|
|
33
|
+
outline: none !important;
|
|
34
|
+
|
|
35
|
+
width: ${({ $fluid }) => ($fluid ? '100%' : 'auto')};
|
|
36
|
+
${({ $buttonSize }) => $buttonSize}
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
outline: none;
|
|
40
|
+
transition: background-color 0.3s, box-shadow 0.3s;
|
|
41
|
+
|
|
42
|
+
&:focus {
|
|
43
|
+
box-shadow: ${shadow.focusShadow};
|
|
44
|
+
}
|
|
45
|
+
`
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { LinkAnchor, StyledLinkButton } from './LinkButton.style'
|
|
3
|
+
import { RippleContainer } from '../Button/Button.styles'
|
|
4
|
+
import { RippleInterface } from '../Button/Button.types'
|
|
5
|
+
import { ButtonVariant } from '../Button/Button.appearance'
|
|
6
|
+
import { LinkButtonSizes } from './LinkButton.size'
|
|
7
|
+
import { LinkButtonProps } from './LinkButton.types'
|
|
8
|
+
|
|
9
|
+
const LinkButton: React.FC<LinkButtonProps> = ({
|
|
10
|
+
href,
|
|
11
|
+
children,
|
|
12
|
+
disabled = false,
|
|
13
|
+
color = 'primary',
|
|
14
|
+
variant = 'ghost',
|
|
15
|
+
fluid = false,
|
|
16
|
+
leftIcon,
|
|
17
|
+
rightIcon,
|
|
18
|
+
onClick,
|
|
19
|
+
size = 'md',
|
|
20
|
+
...rest
|
|
21
|
+
}) => {
|
|
22
|
+
const [ripples, setRipples] = useState<RippleInterface[]>([])
|
|
23
|
+
const buttonVariant = ButtonVariant(color, variant)
|
|
24
|
+
const buttonSize = LinkButtonSizes(size)
|
|
25
|
+
|
|
26
|
+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
27
|
+
if (disabled) {
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
32
|
+
const x = e.clientX - rect.left
|
|
33
|
+
const y = e.clientY - rect.top
|
|
34
|
+
const newRipple: RippleInterface = {
|
|
35
|
+
id: `${Date.now()}-${Math.random()}`,
|
|
36
|
+
x,
|
|
37
|
+
y
|
|
38
|
+
}
|
|
39
|
+
setRipples(prev => [...prev, newRipple])
|
|
40
|
+
onClick?.(e)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<StyledLinkButton
|
|
45
|
+
as={LinkAnchor}
|
|
46
|
+
href={href}
|
|
47
|
+
onClick={handleClick}
|
|
48
|
+
$variant={buttonVariant}
|
|
49
|
+
$buttonSize={buttonSize}
|
|
50
|
+
$fluid={fluid}
|
|
51
|
+
aria-disabled={disabled}
|
|
52
|
+
rel={rest.target === '_blank' ? 'noopener noreferrer' : rest.rel}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
{leftIcon && <span className="link-btn__icon--left">{leftIcon}</span>}
|
|
56
|
+
<span className="link-btn__content">{children}</span>
|
|
57
|
+
{rightIcon && <span className="link-btn__icon--right">{rightIcon}</span>}
|
|
58
|
+
{ripples.map(r => (
|
|
59
|
+
<RippleContainer
|
|
60
|
+
key={r.id}
|
|
61
|
+
$variant={variant}
|
|
62
|
+
style={{
|
|
63
|
+
top: r.y - 10,
|
|
64
|
+
left: r.x - 10,
|
|
65
|
+
width: 20,
|
|
66
|
+
height: 20
|
|
67
|
+
}}
|
|
68
|
+
onAnimationEnd={() => setRipples(prev => prev.filter(x => x.id !== r.id))}
|
|
69
|
+
/>
|
|
70
|
+
))}
|
|
71
|
+
</StyledLinkButton>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default LinkButton
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface LinkButtonProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
2
|
+
href: string
|
|
3
|
+
children: React.ReactNode
|
|
4
|
+
disabled?: boolean
|
|
5
|
+
color?: string
|
|
6
|
+
variant?: 'ghost'
|
|
7
|
+
fluid?: boolean
|
|
8
|
+
leftIcon?: React.ReactNode
|
|
9
|
+
rightIcon?: React.ReactNode
|
|
10
|
+
size?: 'sm' | 'md' | 'lg'
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './LinkButton';
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { css } from 'styled-components'
|
|
2
|
+
import { spacing, shadow } from '@liguelead/foundation'
|
|
3
|
+
import { parseColor } from '../../utils'
|
|
4
|
+
|
|
5
|
+
interface RadioInputVariantProps {
|
|
6
|
+
$error?: boolean
|
|
7
|
+
$variant?: 'default' | 'box'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const RadioInputVariant = (variant: 'default' | 'box') => {
|
|
11
|
+
const variants = {
|
|
12
|
+
default: css<RadioInputVariantProps>`
|
|
13
|
+
appearance: none;
|
|
14
|
+
width: ${spacing.spacing16}px;
|
|
15
|
+
height: ${spacing.spacing16}px;
|
|
16
|
+
border: 1px solid ${({ theme, $error }) =>
|
|
17
|
+
$error ? parseColor(theme.colors.danger200) : parseColor(theme.colors.neutral400)};
|
|
18
|
+
border-radius: 50%;
|
|
19
|
+
background: ${({ theme }) => parseColor(theme.colors.white)};
|
|
20
|
+
position: relative;
|
|
21
|
+
cursor: pointer;
|
|
22
|
+
transition: all 0.2s ease;
|
|
23
|
+
flex-shrink: 0;
|
|
24
|
+
margin-top: 2px;
|
|
25
|
+
|
|
26
|
+
&:checked {
|
|
27
|
+
border-color: ${({ theme, $error }) =>
|
|
28
|
+
$error ? parseColor(theme.colors.danger200) : parseColor(theme.colors.neutral300)};
|
|
29
|
+
|
|
30
|
+
&::after {
|
|
31
|
+
content: '';
|
|
32
|
+
position: absolute;
|
|
33
|
+
top: 50%;
|
|
34
|
+
left: 50%;
|
|
35
|
+
transform: translate(-50%, -50%);
|
|
36
|
+
width: 8px;
|
|
37
|
+
height: 8px;
|
|
38
|
+
border-radius: 50%;
|
|
39
|
+
background: ${({ theme, $error }) =>
|
|
40
|
+
$error ? parseColor(theme.colors.danger200) : parseColor(theme.colors.primary)};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
&:focus {
|
|
45
|
+
outline: none;
|
|
46
|
+
box-shadow: ${({ theme, $error }) =>
|
|
47
|
+
$error
|
|
48
|
+
? `0 0 0 3px ${parseColor(theme.colors.danger100)}40`
|
|
49
|
+
: `${shadow.focusShadow}`
|
|
50
|
+
};
|
|
51
|
+
border-color: ${({ theme, $error }) =>
|
|
52
|
+
$error
|
|
53
|
+
? parseColor(theme.colors.danger200)
|
|
54
|
+
: parseColor(theme.colors.neutral700)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&:disabled {
|
|
59
|
+
cursor: not-allowed;
|
|
60
|
+
border-color: ${({ theme }) => parseColor(theme.colors.neutral400)};
|
|
61
|
+
background: ${({ theme }) => parseColor(theme.colors.neutral100)};
|
|
62
|
+
|
|
63
|
+
&:checked::after {
|
|
64
|
+
background: ${({ theme }) => parseColor(theme.colors.neutral400)};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
`,
|
|
68
|
+
|
|
69
|
+
box: css<RadioInputVariantProps>`
|
|
70
|
+
appearance: none;
|
|
71
|
+
width: ${spacing.spacing16}px;
|
|
72
|
+
height: ${spacing.spacing16}px;
|
|
73
|
+
border: 1px solid ${({ theme, $error }) =>
|
|
74
|
+
$error ? parseColor(theme.colors.danger200) : parseColor(theme.colors.neutral400)};
|
|
75
|
+
border-radius: 50%;
|
|
76
|
+
background: ${({ theme }) => parseColor(theme.colors.white)};
|
|
77
|
+
position: relative;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
transition: all 0.2s ease;
|
|
80
|
+
flex-shrink: 0;
|
|
81
|
+
margin-top: 2px;
|
|
82
|
+
|
|
83
|
+
&:checked {
|
|
84
|
+
border-color: ${({ theme, $error }) =>
|
|
85
|
+
$error ? parseColor(theme.colors.danger200) : 'transparent'};
|
|
86
|
+
background: ${({ theme, $error }) =>
|
|
87
|
+
$error ? parseColor(theme.colors.danger200) : parseColor(theme.colors.primary)};
|
|
88
|
+
|
|
89
|
+
&::after {
|
|
90
|
+
content: '';
|
|
91
|
+
position: absolute;
|
|
92
|
+
top: 50%;
|
|
93
|
+
left: 50%;
|
|
94
|
+
transform: translate(-50%, -50%);
|
|
95
|
+
width: 8px;
|
|
96
|
+
height: 8px;
|
|
97
|
+
border-radius: 50%;
|
|
98
|
+
background: ${({ theme }) => parseColor(theme.colors.white)};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
&:focus {
|
|
103
|
+
outline: none;
|
|
104
|
+
box-shadow: ${({ theme, $error }) =>
|
|
105
|
+
$error
|
|
106
|
+
? `0 0 0 3px ${parseColor(theme.colors.danger100)}40`
|
|
107
|
+
: `${shadow.focusShadow}`
|
|
108
|
+
};
|
|
109
|
+
border-color: ${({ theme, $error }) =>
|
|
110
|
+
$error
|
|
111
|
+
? parseColor(theme.colors.danger200)
|
|
112
|
+
: parseColor(theme.colors.neutral700)
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
&:disabled {
|
|
117
|
+
cursor: not-allowed;
|
|
118
|
+
border-color: ${({ theme }) => parseColor(theme.colors.neutral400)};
|
|
119
|
+
background: ${({ theme }) => parseColor(theme.colors.neutral100)};
|
|
120
|
+
|
|
121
|
+
&:checked {
|
|
122
|
+
background: ${({ theme }) => parseColor(theme.colors.neutral400)};
|
|
123
|
+
|
|
124
|
+
&::after {
|
|
125
|
+
background: ${({ theme }) => parseColor(theme.colors.white)};
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return variants[variant] || variants.default
|
|
133
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import styled from 'styled-components'
|
|
2
|
+
import {
|
|
3
|
+
fontSize,
|
|
4
|
+
fontWeight,
|
|
5
|
+
lineHeight,
|
|
6
|
+
spacing
|
|
7
|
+
} from '@liguelead/foundation'
|
|
8
|
+
import { parseColor } from '../../utils'
|
|
9
|
+
import { RadioButtonVariant } from './RadioButton.variants'
|
|
10
|
+
import { RadioInputVariant } from './RadioButton.inputVariants'
|
|
11
|
+
import { RadioWrapperProps } from './RadioButton.types'
|
|
12
|
+
|
|
13
|
+
export const RadioWrapper = styled.label<RadioWrapperProps>`
|
|
14
|
+
display: flex;
|
|
15
|
+
align-items: flex-start;
|
|
16
|
+
gap: ${spacing.spacing12}px;
|
|
17
|
+
cursor: ${({ $disabled }) => $disabled ? 'not-allowed' : 'pointer'};
|
|
18
|
+
opacity: ${({ $disabled }) => $disabled ? 0.5 : 1};
|
|
19
|
+
transition: all 0.2s ease;
|
|
20
|
+
|
|
21
|
+
${({ $variant = 'default' }) => RadioButtonVariant($variant)}
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
interface RadioInputProps {
|
|
25
|
+
$error?: boolean
|
|
26
|
+
$variant?: 'default' | 'box'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const RadioInput = styled.input<RadioInputProps>`
|
|
30
|
+
${({ $variant = 'default' }) => RadioInputVariant($variant)}
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
export const RadioContent = styled.div`
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
gap: ${spacing.spacing4}px;
|
|
37
|
+
flex: 1;
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
export const RadioContentWithIcon = styled.div`
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: flex-start;
|
|
43
|
+
justify-content: space-between;
|
|
44
|
+
width: 100%;
|
|
45
|
+
`
|
|
46
|
+
|
|
47
|
+
export const RadioIconWrapper = styled.div<{ $error?: boolean }>`
|
|
48
|
+
display: flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
margin-left: ${spacing.spacing8}px;
|
|
51
|
+
flex-shrink: 0;
|
|
52
|
+
|
|
53
|
+
& svg {
|
|
54
|
+
width: 20px;
|
|
55
|
+
height: 20px;
|
|
56
|
+
color: ${({ theme, $error }) =>
|
|
57
|
+
$error ? parseColor(theme.colors.danger100) : parseColor(theme.colors.neutral700)};
|
|
58
|
+
}
|
|
59
|
+
`
|
|
60
|
+
|
|
61
|
+
export const RadioLabel = styled.span<{ $error?: boolean }>`
|
|
62
|
+
font-size: ${fontSize.fontSize14}px;
|
|
63
|
+
font-weight: ${fontWeight.fontWeight500};
|
|
64
|
+
color: ${({ theme, $error }) =>
|
|
65
|
+
$error
|
|
66
|
+
? parseColor(theme.colors.danger100)
|
|
67
|
+
: parseColor(theme.colors.textDark)};
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
export const RadioDescription = styled.span<{ $error?: boolean }>`
|
|
71
|
+
font-size: ${fontSize.fontSize12}px;
|
|
72
|
+
font-weight: ${fontWeight.fontWeight400};
|
|
73
|
+
line-height: ${lineHeight.lineHeight16}px;
|
|
74
|
+
color: ${({ theme, $error }) =>
|
|
75
|
+
$error
|
|
76
|
+
? parseColor(theme.colors.danger100)
|
|
77
|
+
: parseColor(theme.colors.textMedium)};
|
|
78
|
+
`
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { forwardRef } from 'react'
|
|
2
|
+
import { RadioButtonProps } from './RadioButton.types'
|
|
3
|
+
import {
|
|
4
|
+
RadioWrapper,
|
|
5
|
+
RadioInput,
|
|
6
|
+
RadioContent,
|
|
7
|
+
RadioContentWithIcon,
|
|
8
|
+
RadioLabel,
|
|
9
|
+
RadioDescription,
|
|
10
|
+
RadioIconWrapper
|
|
11
|
+
} from './RadioButton.styles'
|
|
12
|
+
|
|
13
|
+
const RadioButton = forwardRef<HTMLInputElement, RadioButtonProps>(
|
|
14
|
+
(
|
|
15
|
+
{
|
|
16
|
+
className,
|
|
17
|
+
disabled = false,
|
|
18
|
+
error,
|
|
19
|
+
label,
|
|
20
|
+
description,
|
|
21
|
+
value,
|
|
22
|
+
checked,
|
|
23
|
+
onChange,
|
|
24
|
+
name,
|
|
25
|
+
register,
|
|
26
|
+
rightIcon,
|
|
27
|
+
variant = 'default',
|
|
28
|
+
...props
|
|
29
|
+
},
|
|
30
|
+
ref
|
|
31
|
+
) => {
|
|
32
|
+
const hasError = !!error
|
|
33
|
+
|
|
34
|
+
const contentElement = (
|
|
35
|
+
<RadioContent>
|
|
36
|
+
<RadioLabel $error={hasError}>
|
|
37
|
+
{label}
|
|
38
|
+
</RadioLabel>
|
|
39
|
+
{description && (
|
|
40
|
+
<RadioDescription $error={hasError}>
|
|
41
|
+
{description}
|
|
42
|
+
</RadioDescription>
|
|
43
|
+
)}
|
|
44
|
+
</RadioContent>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<RadioWrapper
|
|
49
|
+
className={className}
|
|
50
|
+
$disabled={disabled}
|
|
51
|
+
$error={hasError}
|
|
52
|
+
$variant={variant}
|
|
53
|
+
$checked={checked}
|
|
54
|
+
>
|
|
55
|
+
<RadioInput
|
|
56
|
+
ref={ref}
|
|
57
|
+
type="radio"
|
|
58
|
+
value={value}
|
|
59
|
+
$variant={variant}
|
|
60
|
+
checked={checked}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
name={name}
|
|
63
|
+
$error={hasError}
|
|
64
|
+
{...props}
|
|
65
|
+
{...register}
|
|
66
|
+
onChange={(e) => {
|
|
67
|
+
register?.onChange(e)
|
|
68
|
+
onChange?.(e)
|
|
69
|
+
}}
|
|
70
|
+
/>
|
|
71
|
+
{rightIcon ? (
|
|
72
|
+
<RadioContentWithIcon>
|
|
73
|
+
{contentElement}
|
|
74
|
+
<RadioIconWrapper $error={hasError}>
|
|
75
|
+
{rightIcon}
|
|
76
|
+
</RadioIconWrapper>
|
|
77
|
+
</RadioContentWithIcon>
|
|
78
|
+
) : (
|
|
79
|
+
contentElement
|
|
80
|
+
)}
|
|
81
|
+
</RadioWrapper>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
RadioButton.displayName = 'RadioButton'
|
|
87
|
+
|
|
88
|
+
export default RadioButton
|