@charcoal-ui/react 2.0.0-alpha.0 → 2.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.
Files changed (34) 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.story.d.ts +4 -2
  4. package/dist/components/Radio/index.story.d.ts.map +1 -1
  5. package/dist/components/Select/context.d.ts +14 -0
  6. package/dist/components/Select/context.d.ts.map +1 -0
  7. package/dist/components/Select/index.d.ts +24 -0
  8. package/dist/components/Select/index.d.ts.map +1 -0
  9. package/dist/components/Select/index.story.d.ts +75 -0
  10. package/dist/components/Select/index.story.d.ts.map +1 -0
  11. package/dist/components/Select/index.test.d.ts +2 -0
  12. package/dist/components/Select/index.test.d.ts.map +1 -0
  13. package/dist/components/TextField/index.d.ts.map +1 -1
  14. package/dist/components/TextField/index.story.d.ts +9 -3
  15. package/dist/components/TextField/index.story.d.ts.map +1 -1
  16. package/dist/index.cjs +1 -1
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.modern.js +76 -31
  21. package/dist/index.modern.js.map +1 -1
  22. package/dist/index.module.js +1 -1
  23. package/dist/index.module.js.map +1 -1
  24. package/dist/styled.d.ts +2 -0
  25. package/dist/styled.d.ts.map +1 -1
  26. package/package.json +11 -6
  27. package/src/_lib/compat.ts +1 -4
  28. package/src/components/Select/context.ts +23 -0
  29. package/src/components/Select/index.story.tsx +153 -0
  30. package/src/components/Select/index.test.tsx +281 -0
  31. package/src/components/Select/index.tsx +210 -0
  32. package/src/components/TextField/index.tsx +26 -10
  33. package/src/components/a11y.test.tsx +2 -2
  34. package/src/index.ts +6 -0
@@ -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
+ }
@@ -60,8 +60,10 @@ function mergeRefs<T>(...refs: React.Ref<T>[]): React.RefCallback<T> {
60
60
  }
61
61
  }
62
62
 
63
- function countStringInCodePoints(string: string) {
64
- return [...string].length
63
+ function countCodePointsInString(string: string) {
64
+ // [...string] とするとproduction buildで動かなくなる
65
+ // cf. https://twitter.com/f_subal/status/1497214727511891972
66
+ return Array.from(string).length
65
67
  }
66
68
 
67
69
  const TextField = React.forwardRef<TextFieldElement, TextFieldProps>(
@@ -100,22 +102,29 @@ const SingleLineTextField = React.forwardRef<
100
102
  const ariaRef = useRef<HTMLInputElement>(null)
101
103
  const prefixRef = useRef<HTMLSpanElement>(null)
102
104
  const suffixRef = useRef<HTMLSpanElement>(null)
103
- const [count, setCount] = useState(countStringInCodePoints(props.value ?? ''))
105
+ const [count, setCount] = useState(countCodePointsInString(props.value ?? ''))
104
106
  const [prefixWidth, setPrefixWidth] = useState(0)
105
107
  const [suffixWidth, setSuffixWidth] = useState(0)
106
108
 
109
+ const nonControlled = props.value === undefined
107
110
  const handleChange = useCallback(
108
111
  (value: string) => {
109
- const count = countStringInCodePoints(value)
112
+ const count = countCodePointsInString(value)
110
113
  if (maxLength !== undefined && count > maxLength) {
111
114
  return
112
115
  }
113
- setCount(count)
116
+ if (nonControlled) {
117
+ setCount(count)
118
+ }
114
119
  onChange?.(value)
115
120
  },
116
- [maxLength, onChange]
121
+ [maxLength, nonControlled, onChange]
117
122
  )
118
123
 
124
+ useEffect(() => {
125
+ setCount(countCodePointsInString(props.value ?? ''))
126
+ }, [props.value])
127
+
119
128
  const { inputProps, labelProps, descriptionProps, errorMessageProps } =
120
129
  useTextField(
121
130
  {
@@ -217,7 +226,7 @@ const MultiLineTextField = React.forwardRef<
217
226
  const { visuallyHiddenProps } = useVisuallyHidden()
218
227
  const textareaRef = useRef<HTMLTextAreaElement>(null)
219
228
  const ariaRef = useRef<HTMLTextAreaElement>(null)
220
- const [count, setCount] = useState(countStringInCodePoints(props.value ?? ''))
229
+ const [count, setCount] = useState(countCodePointsInString(props.value ?? ''))
221
230
  const [rows, setRows] = useState(initialRows)
222
231
 
223
232
  const syncHeight = useCallback(
@@ -230,21 +239,28 @@ const MultiLineTextField = React.forwardRef<
230
239
  [initialRows]
231
240
  )
232
241
 
242
+ const nonControlled = props.value === undefined
233
243
  const handleChange = useCallback(
234
244
  (value: string) => {
235
- const count = countStringInCodePoints(value)
245
+ const count = countCodePointsInString(value)
236
246
  if (maxLength !== undefined && count > maxLength) {
237
247
  return
238
248
  }
239
- setCount(count)
249
+ if (nonControlled) {
250
+ setCount(count)
251
+ }
240
252
  if (autoHeight && textareaRef.current !== null) {
241
253
  syncHeight(textareaRef.current)
242
254
  }
243
255
  onChange?.(value)
244
256
  },
245
- [autoHeight, maxLength, onChange, syncHeight]
257
+ [autoHeight, maxLength, nonControlled, onChange, syncHeight]
246
258
  )
247
259
 
260
+ useEffect(() => {
261
+ setCount(countCodePointsInString(props.value ?? ''))
262
+ }, [props.value])
263
+
248
264
  const { inputProps, labelProps, descriptionProps, errorMessageProps } =
249
265
  useTextField(
250
266
  {
@@ -69,13 +69,13 @@ describe.each(themes)('using %s theme', (_name, theme) => {
69
69
  describe.each(links)('using %s component', (_name, link) => {
70
70
  describe.each(stories)(
71
71
  'storiesOf($filename).add($name)',
72
- ({ story, args }) => {
72
+ ({ story: Story, args }) => {
73
73
  it('has no accessibility violations', async () => {
74
74
  expect(() => {
75
75
  render(
76
76
  <ThemeProvider theme={theme}>
77
77
  <ComponentAbstraction components={{ Link: link }}>
78
- {story(args)}
78
+ <Story {...args} />
79
79
  </ComponentAbstraction>
80
80
  </ThemeProvider>
81
81
  )
package/src/index.ts CHANGED
@@ -19,6 +19,12 @@ export {
19
19
  RadioGroup,
20
20
  type RadioGroupProps,
21
21
  } from './components/Radio'
22
+ export {
23
+ default as Select,
24
+ type SelectProps,
25
+ SelectGroup,
26
+ type SelectGroupProps,
27
+ } from './components/Select'
22
28
  export { default as Switch, type SwitchProps } from './components/Switch'
23
29
  export {
24
30
  default as TextField,