@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.
Files changed (43) hide show
  1. package/dist/_lib/compat.d.ts +2 -3
  2. package/dist/_lib/compat.d.ts.map +1 -1
  3. package/dist/components/Radio/index.d.ts +2 -2
  4. package/dist/components/Radio/index.d.ts.map +1 -1
  5. package/dist/components/Radio/index.story.d.ts +14 -5
  6. package/dist/components/Radio/index.story.d.ts.map +1 -1
  7. package/dist/components/Select/context.d.ts +14 -0
  8. package/dist/components/Select/context.d.ts.map +1 -0
  9. package/dist/components/Select/index.d.ts +24 -0
  10. package/dist/components/Select/index.d.ts.map +1 -0
  11. package/dist/components/Select/index.story.d.ts +75 -0
  12. package/dist/components/Select/index.story.d.ts.map +1 -0
  13. package/dist/components/Select/index.test.d.ts +2 -0
  14. package/dist/components/Select/index.test.d.ts.map +1 -0
  15. package/dist/components/TextField/index.d.ts +4 -0
  16. package/dist/components/TextField/index.d.ts.map +1 -1
  17. package/dist/components/TextField/index.story.d.ts +11 -4
  18. package/dist/components/TextField/index.story.d.ts.map +1 -1
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.modern.js +97 -35
  24. package/dist/index.modern.js.map +1 -1
  25. package/dist/index.module.js +1 -1
  26. package/dist/index.module.js.map +1 -1
  27. package/dist/styled.d.ts +10 -0
  28. package/dist/styled.d.ts.map +1 -1
  29. package/package.json +11 -6
  30. package/src/_lib/compat.ts +1 -4
  31. package/src/components/IconButton/index.tsx +1 -0
  32. package/src/components/Radio/index.story.tsx +16 -17
  33. package/src/components/Radio/index.test.tsx +15 -16
  34. package/src/components/Radio/index.tsx +4 -7
  35. package/src/components/Select/context.ts +23 -0
  36. package/src/components/Select/index.story.tsx +153 -0
  37. package/src/components/Select/index.test.tsx +281 -0
  38. package/src/components/Select/index.tsx +210 -0
  39. package/src/components/TextField/index.story.tsx +10 -0
  40. package/src/components/TextField/index.tsx +105 -23
  41. package/src/components/a11y.test.tsx +32 -24
  42. package/src/index.ts +6 -0
  43. 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
  )