@charcoal-ui/react 1.0.0 → 2.0.0-alpha.2
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/dist/_lib/compat.d.ts +2 -3
- package/dist/_lib/compat.d.ts.map +1 -1
- package/dist/components/Radio/index.d.ts +2 -2
- package/dist/components/Radio/index.d.ts.map +1 -1
- package/dist/components/Radio/index.story.d.ts +14 -5
- package/dist/components/Radio/index.story.d.ts.map +1 -1
- package/dist/components/Select/context.d.ts +14 -0
- package/dist/components/Select/context.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +24 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Select/index.story.d.ts +75 -0
- package/dist/components/Select/index.story.d.ts.map +1 -0
- package/dist/components/Select/index.test.d.ts +2 -0
- package/dist/components/Select/index.test.d.ts.map +1 -0
- package/dist/components/TextField/index.d.ts +4 -0
- package/dist/components/TextField/index.d.ts.map +1 -1
- package/dist/components/TextField/index.story.d.ts +11 -4
- package/dist/components/TextField/index.story.d.ts.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.modern.js +97 -35
- package/dist/index.modern.js.map +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.module.js.map +1 -1
- package/dist/styled.d.ts +10 -0
- package/dist/styled.d.ts.map +1 -1
- package/package.json +11 -6
- package/src/_lib/compat.ts +1 -4
- package/src/components/IconButton/index.tsx +1 -0
- package/src/components/Radio/index.story.tsx +16 -17
- package/src/components/Radio/index.test.tsx +15 -16
- package/src/components/Radio/index.tsx +4 -7
- package/src/components/Select/context.ts +23 -0
- package/src/components/Select/index.story.tsx +153 -0
- package/src/components/Select/index.test.tsx +281 -0
- package/src/components/Select/index.tsx +210 -0
- package/src/components/TextField/index.story.tsx +10 -0
- package/src/components/TextField/index.tsx +105 -23
- package/src/components/a11y.test.tsx +32 -24
- package/src/index.ts +6 -0
- package/src/type.d.ts +0 -4
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Story } from '../../_lib/compat'
|
|
3
|
+
import styled from 'styled-components'
|
|
4
|
+
import { SelectGroup, default as Select } from '.'
|
|
5
|
+
|
|
6
|
+
export default {
|
|
7
|
+
title: 'Select',
|
|
8
|
+
component: Select,
|
|
9
|
+
argTypes: {
|
|
10
|
+
name: {
|
|
11
|
+
control: {
|
|
12
|
+
type: 'text',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
ariaLabel: {
|
|
16
|
+
control: {
|
|
17
|
+
type: 'text',
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
selected: {
|
|
21
|
+
control: {
|
|
22
|
+
type: 'boolean',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
firstOptionForceChecked: {
|
|
26
|
+
control: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
disabled: {
|
|
31
|
+
control: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
readonly: {
|
|
36
|
+
control: {
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
hasError: {
|
|
41
|
+
control: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
variant: {
|
|
46
|
+
control: {
|
|
47
|
+
type: 'inline-radio',
|
|
48
|
+
options: ['default', 'overlay'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Props = {
|
|
55
|
+
name: string
|
|
56
|
+
ariaLabel: string
|
|
57
|
+
selected: boolean
|
|
58
|
+
firstOptionForceChecked: boolean
|
|
59
|
+
onChange: (selected: string[]) => void
|
|
60
|
+
disabled?: boolean
|
|
61
|
+
readonly?: boolean
|
|
62
|
+
hasError?: boolean
|
|
63
|
+
variant?: 'default' | 'overlay'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const StyledSelectGroup = styled(SelectGroup)`
|
|
67
|
+
display: grid;
|
|
68
|
+
grid-template-columns: 1fr;
|
|
69
|
+
gap: 8px;
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
const Template: Story<Props> = ({
|
|
73
|
+
name,
|
|
74
|
+
ariaLabel,
|
|
75
|
+
selected,
|
|
76
|
+
firstOptionForceChecked,
|
|
77
|
+
onChange,
|
|
78
|
+
disabled,
|
|
79
|
+
readonly,
|
|
80
|
+
hasError,
|
|
81
|
+
variant,
|
|
82
|
+
}) => {
|
|
83
|
+
return (
|
|
84
|
+
<StyledSelectGroup
|
|
85
|
+
{...{
|
|
86
|
+
name,
|
|
87
|
+
ariaLabel,
|
|
88
|
+
onChange,
|
|
89
|
+
disabled,
|
|
90
|
+
readonly,
|
|
91
|
+
hasError,
|
|
92
|
+
}}
|
|
93
|
+
className={''}
|
|
94
|
+
selected={selected ? ['選択肢1', '選択肢3'] : []}
|
|
95
|
+
>
|
|
96
|
+
{[1, 2, 3, 4].map((idx) => (
|
|
97
|
+
<Select
|
|
98
|
+
value={`選択肢${idx}`}
|
|
99
|
+
forceChecked={firstOptionForceChecked && idx === 1}
|
|
100
|
+
variant={variant}
|
|
101
|
+
key={idx}
|
|
102
|
+
>
|
|
103
|
+
選択肢{idx}
|
|
104
|
+
</Select>
|
|
105
|
+
))}
|
|
106
|
+
</StyledSelectGroup>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const Default = Template.bind({})
|
|
111
|
+
Default.args = {
|
|
112
|
+
name: '',
|
|
113
|
+
ariaLabel: '',
|
|
114
|
+
selected: true,
|
|
115
|
+
firstOptionForceChecked: false,
|
|
116
|
+
disabled: false,
|
|
117
|
+
readonly: false,
|
|
118
|
+
hasError: false,
|
|
119
|
+
variant: 'default',
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
onChange: (selected) => console.log(selected),
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
type PlaygroundProps = {
|
|
125
|
+
name: string
|
|
126
|
+
ariaLabel: string
|
|
127
|
+
disabled?: boolean
|
|
128
|
+
readonly?: boolean
|
|
129
|
+
hasError?: boolean
|
|
130
|
+
variant?: 'default' | 'overlay'
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const Playground: Story<PlaygroundProps> = (props) => {
|
|
134
|
+
const [selected, setSelected] = useState<string[]>([])
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<StyledSelectGroup {...props} selected={selected} onChange={setSelected}>
|
|
138
|
+
{[1, 2, 3, 4].map((idx) => (
|
|
139
|
+
<Select value={`選択肢${idx}`} variant={props.variant} key={idx}>
|
|
140
|
+
選択肢{idx}
|
|
141
|
+
</Select>
|
|
142
|
+
))}
|
|
143
|
+
</StyledSelectGroup>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
Playground.args = {
|
|
147
|
+
name: 'defaultName',
|
|
148
|
+
ariaLabel: '',
|
|
149
|
+
disabled: false,
|
|
150
|
+
readonly: false,
|
|
151
|
+
hasError: false,
|
|
152
|
+
variant: 'default',
|
|
153
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import { ThemeProvider } from 'styled-components'
|
|
4
|
+
import { default as Select, SelectGroup } from '.'
|
|
5
|
+
import { light } from '@charcoal-ui/theme'
|
|
6
|
+
|
|
7
|
+
describe('Select', () => {
|
|
8
|
+
describe('in development mode', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
process.env.NODE_ENV = 'development'
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
describe('when `<Select />` is used without `<SelectGroup />`', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
// eslint-disable-next-line no-console
|
|
16
|
+
console.error = jest.fn()
|
|
17
|
+
|
|
18
|
+
render(
|
|
19
|
+
<ThemeProvider theme={light}>
|
|
20
|
+
<Select value="a" />
|
|
21
|
+
</ThemeProvider>
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('emits error message', () => {
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
28
|
+
expect.stringMatching(
|
|
29
|
+
/Perhaps you forgot to wrap with <SelectGroup>/u
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('none of the options selected', () => {
|
|
37
|
+
let option1: HTMLInputElement
|
|
38
|
+
let option2: HTMLInputElement
|
|
39
|
+
let option3: HTMLInputElement
|
|
40
|
+
let allOptions: HTMLInputElement[]
|
|
41
|
+
let parent: HTMLDivElement
|
|
42
|
+
const childOnChange = jest.fn()
|
|
43
|
+
const parentOnChange = jest.fn()
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
render(
|
|
47
|
+
<TestComponent
|
|
48
|
+
selected={[]}
|
|
49
|
+
childOnChange={childOnChange}
|
|
50
|
+
parentOnChange={parentOnChange}
|
|
51
|
+
/>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
option1 = screen.getByDisplayValue('option1')
|
|
55
|
+
option2 = screen.getByDisplayValue('option2')
|
|
56
|
+
option3 = screen.getByDisplayValue('option3')
|
|
57
|
+
allOptions = [option1, option2, option3]
|
|
58
|
+
parent = screen.getByTestId('SelectGroup')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('options have correct name', () => {
|
|
62
|
+
allOptions.forEach((element) => expect(element.name).toBe('defaultName'))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('parent have correct aria-label', () => {
|
|
66
|
+
expect(parent.getAttribute('aria-label')).toBe('defaultAriaLabel')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('none of the options are selected', () => {
|
|
70
|
+
allOptions.forEach((element) => expect(element.checked).toBeFalsy())
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('selecting option1', () => {
|
|
74
|
+
it('childOnChange is called', () => {
|
|
75
|
+
fireEvent.click(option1)
|
|
76
|
+
expect(childOnChange).toHaveBeenCalledWith({
|
|
77
|
+
value: 'option1',
|
|
78
|
+
selected: true,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('parentOnChange is called', () => {
|
|
83
|
+
fireEvent.click(option1)
|
|
84
|
+
expect(parentOnChange).toHaveBeenCalledWith(['option1'])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('option2 is selected', () => {
|
|
90
|
+
let option1: HTMLInputElement
|
|
91
|
+
let option2: HTMLInputElement
|
|
92
|
+
let option3: HTMLInputElement
|
|
93
|
+
const childOnChange = jest.fn()
|
|
94
|
+
const parentOnChange = jest.fn()
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
render(
|
|
98
|
+
<TestComponent
|
|
99
|
+
selected={['option2']}
|
|
100
|
+
childOnChange={childOnChange}
|
|
101
|
+
parentOnChange={parentOnChange}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
option1 = screen.getByDisplayValue('option1')
|
|
106
|
+
option2 = screen.getByDisplayValue('option2')
|
|
107
|
+
option3 = screen.getByDisplayValue('option3')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('only option2 is selected', () => {
|
|
111
|
+
expect(option2.checked).toBeTruthy()
|
|
112
|
+
;[option1, option3].forEach((element) =>
|
|
113
|
+
expect(element.checked).toBeFalsy()
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('selecting option1', () => {
|
|
118
|
+
it('parentOnChange is called', () => {
|
|
119
|
+
fireEvent.click(option1)
|
|
120
|
+
expect(parentOnChange).toHaveBeenCalledWith(['option2', 'option1'])
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('de-selecting option2', () => {
|
|
125
|
+
it('childOnChange is called', () => {
|
|
126
|
+
fireEvent.click(option2)
|
|
127
|
+
expect(childOnChange).toHaveBeenCalledWith({
|
|
128
|
+
value: 'option2',
|
|
129
|
+
selected: false,
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('parentOnChange is called', () => {
|
|
134
|
+
fireEvent.click(option2)
|
|
135
|
+
expect(parentOnChange).toHaveBeenCalledWith([])
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('the group is disabled', () => {
|
|
141
|
+
let option1: HTMLInputElement
|
|
142
|
+
let option2: HTMLInputElement
|
|
143
|
+
let option3: HTMLInputElement
|
|
144
|
+
let allOptions: HTMLInputElement[]
|
|
145
|
+
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
render(<TestComponent selected={['option1']} parentDisabled={true} />)
|
|
148
|
+
|
|
149
|
+
option1 = screen.getByDisplayValue('option1')
|
|
150
|
+
option2 = screen.getByDisplayValue('option2')
|
|
151
|
+
option3 = screen.getByDisplayValue('option3')
|
|
152
|
+
allOptions = [option1, option2, option3]
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('all the options are disabled', () => {
|
|
156
|
+
allOptions.forEach((element) => expect(element.disabled).toBeTruthy())
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('the group is readonly', () => {
|
|
161
|
+
let option1: HTMLInputElement
|
|
162
|
+
let option2: HTMLInputElement
|
|
163
|
+
let option3: HTMLInputElement
|
|
164
|
+
let allOptions: HTMLInputElement[]
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
render(<TestComponent selected={['option1']} readonly={true} />)
|
|
168
|
+
|
|
169
|
+
option1 = screen.getByDisplayValue('option1')
|
|
170
|
+
option2 = screen.getByDisplayValue('option2')
|
|
171
|
+
option3 = screen.getByDisplayValue('option3')
|
|
172
|
+
allOptions = [option1, option2, option3]
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('all the options are disabled', () => {
|
|
176
|
+
allOptions.forEach((element) => expect(element.disabled).toBeTruthy())
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('the group has error', () => {
|
|
181
|
+
let option1: HTMLInputElement
|
|
182
|
+
let option2: HTMLInputElement
|
|
183
|
+
let option3: HTMLInputElement
|
|
184
|
+
let allOptions: HTMLInputElement[]
|
|
185
|
+
|
|
186
|
+
beforeEach(() => {
|
|
187
|
+
render(<TestComponent selected={['option1']} hasError={true} />)
|
|
188
|
+
|
|
189
|
+
option1 = screen.getByDisplayValue('option1')
|
|
190
|
+
option2 = screen.getByDisplayValue('option2')
|
|
191
|
+
option3 = screen.getByDisplayValue('option3')
|
|
192
|
+
allOptions = [option1, option2, option3]
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('all the options have `aria-invalid="true"`', () => {
|
|
196
|
+
allOptions.forEach((element) =>
|
|
197
|
+
expect(element.getAttribute('aria-invalid')).toBeTruthy()
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
describe('option1 is force checked', () => {
|
|
203
|
+
let option1: HTMLInputElement
|
|
204
|
+
|
|
205
|
+
beforeEach(() => {
|
|
206
|
+
render(<TestComponent selected={[]} firstOptionForceChecked={true} />)
|
|
207
|
+
|
|
208
|
+
option1 = screen.getByDisplayValue('option1')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('option1 is force checked', () => {
|
|
212
|
+
expect(option1.checked).toBeTruthy()
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('option1 is disabled', () => {
|
|
217
|
+
let option1: HTMLInputElement
|
|
218
|
+
let option2: HTMLInputElement
|
|
219
|
+
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
render(<TestComponent selected={[]} firstOptionDisabled={true} />)
|
|
222
|
+
|
|
223
|
+
option1 = screen.getByDisplayValue('option1')
|
|
224
|
+
option2 = screen.getByDisplayValue('option2')
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('only option1 is disabled', () => {
|
|
228
|
+
expect(option1.disabled).toBeTruthy()
|
|
229
|
+
expect(option2.disabled).toBeFalsy()
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const TestComponent = ({
|
|
235
|
+
selected,
|
|
236
|
+
parentOnChange = () => {
|
|
237
|
+
return
|
|
238
|
+
},
|
|
239
|
+
childOnChange,
|
|
240
|
+
parentDisabled = false,
|
|
241
|
+
readonly = false,
|
|
242
|
+
hasError = false,
|
|
243
|
+
firstOptionForceChecked = false,
|
|
244
|
+
firstOptionDisabled = false,
|
|
245
|
+
}: {
|
|
246
|
+
selected: string[]
|
|
247
|
+
parentOnChange?: (selected: string[]) => void
|
|
248
|
+
childOnChange?: (payload: { value: string; selected: boolean }) => void
|
|
249
|
+
parentDisabled?: boolean
|
|
250
|
+
readonly?: boolean
|
|
251
|
+
hasError?: boolean
|
|
252
|
+
firstOptionForceChecked?: boolean
|
|
253
|
+
firstOptionDisabled?: boolean
|
|
254
|
+
}) => {
|
|
255
|
+
return (
|
|
256
|
+
<ThemeProvider theme={light}>
|
|
257
|
+
<SelectGroup
|
|
258
|
+
name="defaultName"
|
|
259
|
+
ariaLabel="defaultAriaLabel"
|
|
260
|
+
disabled={parentDisabled}
|
|
261
|
+
onChange={parentOnChange}
|
|
262
|
+
{...{ selected, readonly, hasError }}
|
|
263
|
+
>
|
|
264
|
+
<Select
|
|
265
|
+
value="option1"
|
|
266
|
+
disabled={firstOptionDisabled}
|
|
267
|
+
forceChecked={firstOptionForceChecked}
|
|
268
|
+
onChange={childOnChange}
|
|
269
|
+
>
|
|
270
|
+
Option 1
|
|
271
|
+
</Select>
|
|
272
|
+
<Select value="option2" onChange={childOnChange}>
|
|
273
|
+
Option 2
|
|
274
|
+
</Select>
|
|
275
|
+
<Select value="option3" onChange={childOnChange}>
|
|
276
|
+
Option 3
|
|
277
|
+
</Select>
|
|
278
|
+
</SelectGroup>
|
|
279
|
+
</ThemeProvider>
|
|
280
|
+
)
|
|
281
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import React, { ChangeEvent, useCallback, useContext } from 'react'
|
|
2
|
+
import styled, { css } from 'styled-components'
|
|
3
|
+
import warning from 'warning'
|
|
4
|
+
import { theme } from '../../styled'
|
|
5
|
+
import { disabledSelector, px } from '@charcoal-ui/utils'
|
|
6
|
+
|
|
7
|
+
import { SelectGroupContext } from './context'
|
|
8
|
+
|
|
9
|
+
export type SelectProps = React.PropsWithChildren<{
|
|
10
|
+
value: string
|
|
11
|
+
forceChecked?: boolean
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
variant?: 'default' | 'overlay'
|
|
14
|
+
onChange?: (payload: { value: string; selected: boolean }) => void
|
|
15
|
+
}>
|
|
16
|
+
|
|
17
|
+
export default function Select({
|
|
18
|
+
value,
|
|
19
|
+
forceChecked = false,
|
|
20
|
+
disabled = false,
|
|
21
|
+
onChange,
|
|
22
|
+
variant = 'default',
|
|
23
|
+
children,
|
|
24
|
+
}: SelectProps) {
|
|
25
|
+
const {
|
|
26
|
+
name,
|
|
27
|
+
selected,
|
|
28
|
+
disabled: parentDisabled,
|
|
29
|
+
readonly,
|
|
30
|
+
hasError,
|
|
31
|
+
onChange: parentOnChange,
|
|
32
|
+
} = useContext(SelectGroupContext)
|
|
33
|
+
|
|
34
|
+
warning(
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
36
|
+
name !== undefined,
|
|
37
|
+
`"name" is not Provided for <Select>. Perhaps you forgot to wrap with <SelectGroup> ?`
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const isSelected = selected.includes(value) || forceChecked
|
|
41
|
+
const isDisabled = disabled || parentDisabled || readonly
|
|
42
|
+
|
|
43
|
+
const handleChange = useCallback(
|
|
44
|
+
(event: ChangeEvent<HTMLInputElement>) => {
|
|
45
|
+
if (!(event.currentTarget instanceof HTMLInputElement)) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (onChange) onChange({ value, selected: event.currentTarget.checked })
|
|
49
|
+
parentOnChange({ value, selected: event.currentTarget.checked })
|
|
50
|
+
},
|
|
51
|
+
[onChange, parentOnChange, value]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<SelectRoot aria-disabled={isDisabled}>
|
|
56
|
+
<SelectInput
|
|
57
|
+
{...{
|
|
58
|
+
name,
|
|
59
|
+
value,
|
|
60
|
+
hasError,
|
|
61
|
+
}}
|
|
62
|
+
checked={isSelected}
|
|
63
|
+
disabled={isDisabled}
|
|
64
|
+
onChange={handleChange}
|
|
65
|
+
overlay={variant === 'overlay'}
|
|
66
|
+
aria-invalid={hasError}
|
|
67
|
+
/>
|
|
68
|
+
<SelectInputOverlay
|
|
69
|
+
overlay={variant === 'overlay'}
|
|
70
|
+
hasError={hasError}
|
|
71
|
+
aria-hidden={true}
|
|
72
|
+
>
|
|
73
|
+
<pixiv-icon name="24/Check" unsafe-non-guideline-scale={16 / 24} />
|
|
74
|
+
</SelectInputOverlay>
|
|
75
|
+
{Boolean(children) && <SelectLabel>{children}</SelectLabel>}
|
|
76
|
+
</SelectRoot>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SelectRoot = styled.label`
|
|
81
|
+
display: grid;
|
|
82
|
+
grid-template-columns: auto 1fr;
|
|
83
|
+
align-items: center;
|
|
84
|
+
position: relative;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
${disabledSelector} {
|
|
87
|
+
cursor: default;
|
|
88
|
+
}
|
|
89
|
+
gap: ${({ theme }) => px(theme.spacing[4])};
|
|
90
|
+
${theme((o) => o.disabled)}
|
|
91
|
+
`
|
|
92
|
+
|
|
93
|
+
const SelectLabel = styled.div`
|
|
94
|
+
display: flex;
|
|
95
|
+
align-items: center;
|
|
96
|
+
${theme((o) => [o.typography(14), o.font.text1])}
|
|
97
|
+
`
|
|
98
|
+
|
|
99
|
+
const SelectInput = styled.input.attrs({ type: 'checkbox' })<{
|
|
100
|
+
hasError: boolean
|
|
101
|
+
overlay: boolean
|
|
102
|
+
}>`
|
|
103
|
+
&[type='checkbox'] {
|
|
104
|
+
appearance: none;
|
|
105
|
+
display: block;
|
|
106
|
+
width: 20px;
|
|
107
|
+
height: 20px;
|
|
108
|
+
margin: 0;
|
|
109
|
+
|
|
110
|
+
&:checked {
|
|
111
|
+
${theme((o) => o.bg.brand.hover.press)}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
${({ hasError, overlay }) =>
|
|
115
|
+
theme((o) => [
|
|
116
|
+
o.bg.text3.hover.press,
|
|
117
|
+
o.borderRadius('oval'),
|
|
118
|
+
hasError && !overlay && o.outline.assertive,
|
|
119
|
+
overlay && o.bg.surface4,
|
|
120
|
+
])};
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
|
|
124
|
+
const SelectInputOverlay = styled.div<{ overlay: boolean; hasError: boolean }>`
|
|
125
|
+
position: absolute;
|
|
126
|
+
top: -2px;
|
|
127
|
+
left: -2px;
|
|
128
|
+
box-sizing: border-box;
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
justify-content: center;
|
|
132
|
+
|
|
133
|
+
${({ hasError, overlay }) =>
|
|
134
|
+
theme((o) => [
|
|
135
|
+
o.width.px(24),
|
|
136
|
+
o.height.px(24),
|
|
137
|
+
o.borderRadius('oval'),
|
|
138
|
+
o.font.text5,
|
|
139
|
+
hasError && overlay && o.outline.assertive,
|
|
140
|
+
])}
|
|
141
|
+
|
|
142
|
+
${({ overlay }) =>
|
|
143
|
+
overlay &&
|
|
144
|
+
css`
|
|
145
|
+
border-color: ${({ theme }) => theme.color.text5};
|
|
146
|
+
border-width: 2px;
|
|
147
|
+
border-style: solid;
|
|
148
|
+
`}
|
|
149
|
+
`
|
|
150
|
+
|
|
151
|
+
export type SelectGroupProps = React.PropsWithChildren<{
|
|
152
|
+
className?: string
|
|
153
|
+
name: string
|
|
154
|
+
ariaLabel: string
|
|
155
|
+
selected: string[]
|
|
156
|
+
onChange: (selected: string[]) => void
|
|
157
|
+
disabled?: boolean
|
|
158
|
+
readonly?: boolean
|
|
159
|
+
hasError?: boolean
|
|
160
|
+
}>
|
|
161
|
+
|
|
162
|
+
export function SelectGroup({
|
|
163
|
+
className,
|
|
164
|
+
name,
|
|
165
|
+
ariaLabel,
|
|
166
|
+
selected,
|
|
167
|
+
onChange,
|
|
168
|
+
disabled = false,
|
|
169
|
+
readonly = false,
|
|
170
|
+
hasError = false,
|
|
171
|
+
children,
|
|
172
|
+
}: SelectGroupProps) {
|
|
173
|
+
const handleChange = useCallback(
|
|
174
|
+
(payload: { value: string; selected: boolean }) => {
|
|
175
|
+
const index = selected.indexOf(payload.value)
|
|
176
|
+
|
|
177
|
+
if (payload.selected) {
|
|
178
|
+
if (index < 0) {
|
|
179
|
+
onChange([...selected, payload.value])
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
if (index >= 0) {
|
|
183
|
+
onChange([...selected.slice(0, index), ...selected.slice(index + 1)])
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[onChange, selected]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<SelectGroupContext.Provider
|
|
192
|
+
value={{
|
|
193
|
+
name,
|
|
194
|
+
selected: Array.from(new Set(selected)),
|
|
195
|
+
disabled,
|
|
196
|
+
readonly,
|
|
197
|
+
hasError,
|
|
198
|
+
onChange: handleChange,
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
className={className}
|
|
203
|
+
aria-label={ariaLabel}
|
|
204
|
+
data-testid="SelectGroup"
|
|
205
|
+
>
|
|
206
|
+
{children}
|
|
207
|
+
</div>
|
|
208
|
+
</SelectGroupContext.Provider>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
@@ -75,6 +75,16 @@ HasCount.args = {
|
|
|
75
75
|
maxLength: 100,
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
export const HasAffix: Story<Partial<SingleLineTextFieldProps>> = (args) => (
|
|
79
|
+
<TextField label="Label" placeholder="path/to/your/file" {...args} />
|
|
80
|
+
)
|
|
81
|
+
HasAffix.args = {
|
|
82
|
+
showCount: true,
|
|
83
|
+
maxLength: 200,
|
|
84
|
+
prefix: '/home/john/',
|
|
85
|
+
suffix: '.png',
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
export const AutoHeight: Story<Partial<MultiLineTextFieldProps>> = (args) => (
|
|
79
89
|
<TextField label="Label" placeholder="Multi Line" {...args} multiline />
|
|
80
90
|
)
|