@charcoal-ui/react 1.0.0-alpha.1
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/LICENSE +201 -0
- package/README.md +18 -0
- package/package.json +75 -0
- package/src/_lib/compat.ts +15 -0
- package/src/_lib/index.ts +35 -0
- package/src/components/Button/index.story.tsx +187 -0
- package/src/components/Button/index.tsx +122 -0
- package/src/components/Clickable/index.story.tsx +18 -0
- package/src/components/Clickable/index.tsx +108 -0
- package/src/components/FieldLabel/index.tsx +73 -0
- package/src/components/IconButton/index.story.tsx +51 -0
- package/src/components/IconButton/index.tsx +118 -0
- package/src/components/Radio/index.story.tsx +79 -0
- package/src/components/Radio/index.test.tsx +149 -0
- package/src/components/Radio/index.tsx +202 -0
- package/src/components/Switch/index.story.tsx +43 -0
- package/src/components/Switch/index.tsx +116 -0
- package/src/components/TextField/index.story.tsx +83 -0
- package/src/components/TextField/index.tsx +386 -0
- package/src/components/a11y.test.tsx +82 -0
- package/src/core/ComponentAbstraction.tsx +47 -0
- package/src/index.ts +21 -0
- package/src/styled.ts +3 -0
- package/src/type.d.ts +16 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useCallback, useContext, useState } from 'react'
|
|
2
|
+
import styled from 'styled-components'
|
|
3
|
+
import warning from 'warning'
|
|
4
|
+
import { theme } from '../../styled'
|
|
5
|
+
import { px } from '@charcoal-ui/utils'
|
|
6
|
+
|
|
7
|
+
export type RadioProps = React.PropsWithChildren<{
|
|
8
|
+
value: string
|
|
9
|
+
forceChecked?: boolean
|
|
10
|
+
disabled?: boolean
|
|
11
|
+
}>
|
|
12
|
+
|
|
13
|
+
export default function Radio({
|
|
14
|
+
value,
|
|
15
|
+
forceChecked = false,
|
|
16
|
+
disabled = false,
|
|
17
|
+
children,
|
|
18
|
+
}: RadioProps) {
|
|
19
|
+
const {
|
|
20
|
+
name,
|
|
21
|
+
selected,
|
|
22
|
+
disabled: isParentDisabled,
|
|
23
|
+
readonly,
|
|
24
|
+
hasError,
|
|
25
|
+
onChange,
|
|
26
|
+
} = useContext(RadioGroupContext)
|
|
27
|
+
|
|
28
|
+
warning(
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
30
|
+
name !== undefined,
|
|
31
|
+
`"name" is not Provided for <Radio>. Perhaps you forgot to wrap with <RadioGroup> ?`
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const isSelected = value === selected
|
|
35
|
+
const isDisabled = disabled || isParentDisabled
|
|
36
|
+
const isReadonly = readonly && !isSelected
|
|
37
|
+
|
|
38
|
+
const handleChange = useCallback(
|
|
39
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
40
|
+
onChange(e.currentTarget.value)
|
|
41
|
+
},
|
|
42
|
+
[onChange]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<RadioRoot aria-disabled={isDisabled || isReadonly}>
|
|
47
|
+
<RadioInput
|
|
48
|
+
name={name}
|
|
49
|
+
value={value}
|
|
50
|
+
checked={forceChecked || isSelected}
|
|
51
|
+
hasError={hasError}
|
|
52
|
+
onChange={handleChange}
|
|
53
|
+
disabled={isDisabled || isReadonly}
|
|
54
|
+
/>
|
|
55
|
+
{children != null && <RadioLabel>{children}</RadioLabel>}
|
|
56
|
+
</RadioRoot>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const RadioRoot = styled.label`
|
|
61
|
+
display: grid;
|
|
62
|
+
grid-template-columns: auto 1fr;
|
|
63
|
+
grid-gap: ${({ theme }) => px(theme.spacing[4])};
|
|
64
|
+
align-items: center;
|
|
65
|
+
cursor: pointer;
|
|
66
|
+
|
|
67
|
+
${theme((o) => [o.disabled])}
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
export const RadioInput = styled.input.attrs({ type: 'radio' })<{
|
|
71
|
+
hasError?: boolean
|
|
72
|
+
}>`
|
|
73
|
+
/** Make prior to browser default style */
|
|
74
|
+
&[type='radio'] {
|
|
75
|
+
appearance: none;
|
|
76
|
+
display: block;
|
|
77
|
+
box-sizing: border-box;
|
|
78
|
+
|
|
79
|
+
padding: 6px;
|
|
80
|
+
|
|
81
|
+
width: 20px;
|
|
82
|
+
height: 20px;
|
|
83
|
+
|
|
84
|
+
${({ hasError = false }) =>
|
|
85
|
+
theme((o) => [
|
|
86
|
+
o.borderRadius('oval'),
|
|
87
|
+
o.bg.text5.hover.press,
|
|
88
|
+
hasError && o.outline.assertive,
|
|
89
|
+
])};
|
|
90
|
+
|
|
91
|
+
&:not(:checked) {
|
|
92
|
+
border-width: 2px;
|
|
93
|
+
border-style: solid;
|
|
94
|
+
border-color: ${({ theme }) => theme.color.text4};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
&:checked {
|
|
98
|
+
${theme((o) => o.bg.brand.hover.press)}
|
|
99
|
+
|
|
100
|
+
&::after {
|
|
101
|
+
content: '';
|
|
102
|
+
display: block;
|
|
103
|
+
width: 8px;
|
|
104
|
+
height: 8px;
|
|
105
|
+
pointer-events: none;
|
|
106
|
+
|
|
107
|
+
${theme((o) => [o.bg.text5.hover.press, o.borderRadius('oval')])}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
${theme((o) => o.outline.default.focus)}
|
|
112
|
+
}
|
|
113
|
+
`
|
|
114
|
+
|
|
115
|
+
const RadioLabel = styled.div`
|
|
116
|
+
${theme((o) => [o.typography(14)])}
|
|
117
|
+
`
|
|
118
|
+
|
|
119
|
+
export type RadioGroupProps = React.PropsWithChildren<{
|
|
120
|
+
className?: string
|
|
121
|
+
defaultValue?: string
|
|
122
|
+
label: string
|
|
123
|
+
name: string
|
|
124
|
+
onChange(next: string): void
|
|
125
|
+
disabled?: boolean
|
|
126
|
+
readonly?: boolean
|
|
127
|
+
hasError?: boolean
|
|
128
|
+
}>
|
|
129
|
+
|
|
130
|
+
// TODO: use (or polyfill) flex gap
|
|
131
|
+
const StyledRadioGroup = styled.div`
|
|
132
|
+
display: grid;
|
|
133
|
+
grid-template-columns: 1fr;
|
|
134
|
+
grid-gap: ${({ theme }) => px(theme.spacing[8])};
|
|
135
|
+
`
|
|
136
|
+
|
|
137
|
+
interface RadioGroupContext {
|
|
138
|
+
name: string
|
|
139
|
+
selected?: string
|
|
140
|
+
disabled: boolean
|
|
141
|
+
readonly: boolean
|
|
142
|
+
hasError: boolean
|
|
143
|
+
onChange: (next: string) => void
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const RadioGroupContext = React.createContext<RadioGroupContext>({
|
|
147
|
+
name: undefined as never,
|
|
148
|
+
selected: undefined,
|
|
149
|
+
disabled: false,
|
|
150
|
+
readonly: false,
|
|
151
|
+
hasError: false,
|
|
152
|
+
onChange() {
|
|
153
|
+
throw new Error(
|
|
154
|
+
'Cannot find onChange() handler. Perhaps you forgot to wrap with <RadioGroup> ?'
|
|
155
|
+
)
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
export function RadioGroup({
|
|
160
|
+
className,
|
|
161
|
+
defaultValue,
|
|
162
|
+
label,
|
|
163
|
+
name,
|
|
164
|
+
onChange,
|
|
165
|
+
disabled,
|
|
166
|
+
readonly,
|
|
167
|
+
hasError,
|
|
168
|
+
children,
|
|
169
|
+
}: RadioGroupProps) {
|
|
170
|
+
const [selected, setSelected] = useState(defaultValue)
|
|
171
|
+
|
|
172
|
+
const handleChange = useCallback(
|
|
173
|
+
(next: string) => {
|
|
174
|
+
setSelected(next)
|
|
175
|
+
onChange(next)
|
|
176
|
+
},
|
|
177
|
+
[onChange]
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<RadioGroupContext.Provider
|
|
182
|
+
value={{
|
|
183
|
+
name,
|
|
184
|
+
selected,
|
|
185
|
+
disabled: disabled ?? false,
|
|
186
|
+
readonly: readonly ?? false,
|
|
187
|
+
hasError: hasError ?? false,
|
|
188
|
+
onChange: handleChange,
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
<StyledRadioGroup
|
|
192
|
+
role="radiogroup"
|
|
193
|
+
aria-orientation="vertical"
|
|
194
|
+
aria-label={label}
|
|
195
|
+
aria-invalid={hasError}
|
|
196
|
+
className={className}
|
|
197
|
+
>
|
|
198
|
+
{children}
|
|
199
|
+
</StyledRadioGroup>
|
|
200
|
+
</RadioGroupContext.Provider>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { action } from '@storybook/addon-actions'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { Story } from '../../_lib/compat'
|
|
4
|
+
import Switch from '.'
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: 'Switch',
|
|
8
|
+
component: Switch,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
checked: boolean
|
|
13
|
+
disabled: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Labelled: Story<Props> = (props: Props) => (
|
|
17
|
+
<div>
|
|
18
|
+
<Switch {...props} name="name" onChange={action('onChange')}>
|
|
19
|
+
選択する
|
|
20
|
+
</Switch>
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
Labelled.args = {
|
|
25
|
+
checked: false,
|
|
26
|
+
disabled: false,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Unlabelled: Story<Props> = (props: Props) => (
|
|
30
|
+
<div>
|
|
31
|
+
<Switch
|
|
32
|
+
{...props}
|
|
33
|
+
name="name"
|
|
34
|
+
label="label"
|
|
35
|
+
onChange={action('onChange')}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
Unlabelled.args = {
|
|
41
|
+
checked: false,
|
|
42
|
+
disabled: false,
|
|
43
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useSwitch } from '@react-aria/switch'
|
|
2
|
+
import type { AriaSwitchProps } from '@react-types/switch'
|
|
3
|
+
import React, { useRef, useMemo } from 'react'
|
|
4
|
+
import { useToggleState } from 'react-stately'
|
|
5
|
+
import styled from 'styled-components'
|
|
6
|
+
import { theme } from '../../styled'
|
|
7
|
+
import { disabledSelector, px } from '@charcoal-ui/utils'
|
|
8
|
+
|
|
9
|
+
export type SwitchProps = {
|
|
10
|
+
name: string
|
|
11
|
+
className?: string
|
|
12
|
+
value?: string
|
|
13
|
+
checked?: boolean
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
onChange(checked: boolean): void
|
|
16
|
+
} & (
|
|
17
|
+
| // children か label は片方が必須
|
|
18
|
+
{
|
|
19
|
+
children: React.ReactNode
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
label: string
|
|
23
|
+
}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
export default function SwitchCheckbox(props: SwitchProps) {
|
|
27
|
+
const { disabled, className } = props
|
|
28
|
+
|
|
29
|
+
const ariaSwitchProps: AriaSwitchProps = useMemo(
|
|
30
|
+
() => ({
|
|
31
|
+
...props,
|
|
32
|
+
|
|
33
|
+
// children がいない場合は aria-label をつけないといけない
|
|
34
|
+
'aria-label': 'children' in props ? undefined : props.label,
|
|
35
|
+
isDisabled: props.disabled,
|
|
36
|
+
isSelected: props.checked,
|
|
37
|
+
}),
|
|
38
|
+
[props]
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const state = useToggleState(ariaSwitchProps)
|
|
42
|
+
const ref = useRef<HTMLInputElement>(null)
|
|
43
|
+
const {
|
|
44
|
+
inputProps: { className: _className, type: _type, ...rest },
|
|
45
|
+
} = useSwitch(ariaSwitchProps, state, ref)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Label className={className} aria-disabled={disabled}>
|
|
49
|
+
<SwitchInput {...rest} ref={ref} />
|
|
50
|
+
{'children' in props ? (
|
|
51
|
+
// eslint-disable-next-line react/destructuring-assignment
|
|
52
|
+
<LabelInner>{props.children}</LabelInner>
|
|
53
|
+
) : undefined}
|
|
54
|
+
</Label>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const Label = styled.label`
|
|
59
|
+
display: inline-grid;
|
|
60
|
+
grid-template-columns: auto 1fr;
|
|
61
|
+
gap: ${({ theme }) => px(theme.spacing[4])};
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
outline: 0;
|
|
64
|
+
|
|
65
|
+
${theme((o) => o.disabled)}
|
|
66
|
+
|
|
67
|
+
${disabledSelector} {
|
|
68
|
+
cursor: default;
|
|
69
|
+
}
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
const LabelInner = styled.div`
|
|
73
|
+
${theme((o) => o.typography(14))}
|
|
74
|
+
`
|
|
75
|
+
|
|
76
|
+
const SwitchInput = styled.input.attrs({
|
|
77
|
+
type: 'checkbox',
|
|
78
|
+
})`
|
|
79
|
+
&[type='checkbox'] {
|
|
80
|
+
appearance: none;
|
|
81
|
+
display: inline-flex;
|
|
82
|
+
position: relative;
|
|
83
|
+
box-sizing: border-box;
|
|
84
|
+
width: 28px;
|
|
85
|
+
border: 2px solid transparent;
|
|
86
|
+
transition: box-shadow 0.2s, background-color 0.2s;
|
|
87
|
+
cursor: inherit;
|
|
88
|
+
${theme((o) => [
|
|
89
|
+
o.borderRadius(16),
|
|
90
|
+
o.height.px(16),
|
|
91
|
+
o.bg.text4.hover.press,
|
|
92
|
+
o.outline.default.focus,
|
|
93
|
+
])}
|
|
94
|
+
|
|
95
|
+
&::after {
|
|
96
|
+
content: '';
|
|
97
|
+
position: absolute;
|
|
98
|
+
display: block;
|
|
99
|
+
top: 0;
|
|
100
|
+
left: 0;
|
|
101
|
+
width: 12px;
|
|
102
|
+
height: 12px;
|
|
103
|
+
transform: translateX(0);
|
|
104
|
+
transition: transform 0.2s;
|
|
105
|
+
${theme((o) => [o.bg.text5.hover.press, o.borderRadius('oval')])}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
&:checked {
|
|
109
|
+
${theme((o) => o.bg.brand.hover.press)}
|
|
110
|
+
|
|
111
|
+
&::after {
|
|
112
|
+
transform: translateX(12px);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
`
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { action } from '@storybook/addon-actions'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { css } from 'styled-components'
|
|
4
|
+
import { Story } from '../../_lib/compat'
|
|
5
|
+
import Clickable from '../Clickable'
|
|
6
|
+
import TextField, {
|
|
7
|
+
MultiLineTextFieldProps,
|
|
8
|
+
SingleLineTextFieldProps,
|
|
9
|
+
TextFieldProps,
|
|
10
|
+
} from '.'
|
|
11
|
+
import { px } from '@charcoal-ui/utils'
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
title: 'TextField',
|
|
15
|
+
component: TextField,
|
|
16
|
+
argTypes: {},
|
|
17
|
+
args: {
|
|
18
|
+
showLabel: false,
|
|
19
|
+
label: 'Label',
|
|
20
|
+
assistiveText: '',
|
|
21
|
+
disabled: false,
|
|
22
|
+
required: false,
|
|
23
|
+
invalid: false,
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const Template: Story<Partial<TextFieldProps>> = (args) => (
|
|
28
|
+
<div
|
|
29
|
+
css={css`
|
|
30
|
+
display: grid;
|
|
31
|
+
gap: ${({ theme }) => px(theme.spacing[24])};
|
|
32
|
+
`}
|
|
33
|
+
>
|
|
34
|
+
<TextField
|
|
35
|
+
label="Label"
|
|
36
|
+
requiredText="*必須"
|
|
37
|
+
subLabel={
|
|
38
|
+
<Clickable to="#" onClick={action('click')}>
|
|
39
|
+
Text Link
|
|
40
|
+
</Clickable>
|
|
41
|
+
}
|
|
42
|
+
placeholder="Single Line"
|
|
43
|
+
onChange={action('change')}
|
|
44
|
+
{...(args as Partial<SingleLineTextFieldProps>)}
|
|
45
|
+
multiline={false}
|
|
46
|
+
/>
|
|
47
|
+
<TextField
|
|
48
|
+
label="Label"
|
|
49
|
+
requiredText="*必須"
|
|
50
|
+
subLabel={
|
|
51
|
+
<Clickable to="#" onClick={action('click')}>
|
|
52
|
+
Text Link
|
|
53
|
+
</Clickable>
|
|
54
|
+
}
|
|
55
|
+
placeholder="Multi Line"
|
|
56
|
+
onChange={action('change')}
|
|
57
|
+
{...(args as Partial<MultiLineTextFieldProps>)}
|
|
58
|
+
multiline
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
export const Default = Template.bind({})
|
|
64
|
+
|
|
65
|
+
export const HasLabel = Template.bind({})
|
|
66
|
+
HasLabel.args = {
|
|
67
|
+
showLabel: true,
|
|
68
|
+
assistiveText: 'Assistive text',
|
|
69
|
+
required: true,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const HasCount = Template.bind({})
|
|
73
|
+
HasCount.args = {
|
|
74
|
+
showCount: true,
|
|
75
|
+
maxLength: 100,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const AutoHeight: Story<Partial<MultiLineTextFieldProps>> = (args) => (
|
|
79
|
+
<TextField label="Label" placeholder="Multi Line" {...args} multiline />
|
|
80
|
+
)
|
|
81
|
+
AutoHeight.args = {
|
|
82
|
+
autoHeight: true,
|
|
83
|
+
}
|