@charcoal-ui/react 1.0.0 → 2.0.0-alpha.10

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 (49) 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/Icon/index.d.ts +12 -0
  4. package/dist/components/Icon/index.d.ts.map +1 -0
  5. package/dist/components/Icon/index.story.d.ts +24 -0
  6. package/dist/components/Icon/index.story.d.ts.map +1 -0
  7. package/dist/components/Radio/index.d.ts +2 -2
  8. package/dist/components/Radio/index.d.ts.map +1 -1
  9. package/dist/components/Radio/index.story.d.ts +14 -5
  10. package/dist/components/Radio/index.story.d.ts.map +1 -1
  11. package/dist/components/Select/context.d.ts +14 -0
  12. package/dist/components/Select/context.d.ts.map +1 -0
  13. package/dist/components/Select/index.d.ts +24 -0
  14. package/dist/components/Select/index.d.ts.map +1 -0
  15. package/dist/components/Select/index.story.d.ts +75 -0
  16. package/dist/components/Select/index.story.d.ts.map +1 -0
  17. package/dist/components/Select/index.test.d.ts +2 -0
  18. package/dist/components/Select/index.test.d.ts.map +1 -0
  19. package/dist/components/TextField/index.d.ts +4 -0
  20. package/dist/components/TextField/index.d.ts.map +1 -1
  21. package/dist/components/TextField/index.story.d.ts +11 -4
  22. package/dist/components/TextField/index.story.d.ts.map +1 -1
  23. package/dist/index.cjs +1 -1
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.ts +2 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.modern.js +97 -35
  28. package/dist/index.modern.js.map +1 -1
  29. package/dist/index.module.js +1 -1
  30. package/dist/index.module.js.map +1 -1
  31. package/dist/styled.d.ts +10 -0
  32. package/dist/styled.d.ts.map +1 -1
  33. package/package.json +11 -6
  34. package/src/_lib/compat.ts +1 -4
  35. package/src/components/Icon/index.story.tsx +29 -0
  36. package/src/components/Icon/index.tsx +33 -0
  37. package/src/components/IconButton/index.tsx +1 -0
  38. package/src/components/Radio/index.story.tsx +16 -17
  39. package/src/components/Radio/index.test.tsx +15 -16
  40. package/src/components/Radio/index.tsx +4 -7
  41. package/src/components/Select/context.ts +23 -0
  42. package/src/components/Select/index.story.tsx +153 -0
  43. package/src/components/Select/index.test.tsx +281 -0
  44. package/src/components/Select/index.tsx +210 -0
  45. package/src/components/TextField/index.story.tsx +10 -0
  46. package/src/components/TextField/index.tsx +106 -24
  47. package/src/components/a11y.test.tsx +32 -24
  48. package/src/index.ts +7 -0
  49. package/src/type.d.ts +0 -4
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useContext, useState } from 'react'
1
+ import React, { useCallback, useContext } from 'react'
2
2
  import styled from 'styled-components'
3
3
  import warning from 'warning'
4
4
  import { theme } from '../../styled'
@@ -118,7 +118,7 @@ const RadioLabel = styled.div`
118
118
 
119
119
  export type RadioGroupProps = React.PropsWithChildren<{
120
120
  className?: string
121
- defaultValue?: string
121
+ value?: string
122
122
  label: string
123
123
  name: string
124
124
  onChange(next: string): void
@@ -158,7 +158,7 @@ const RadioGroupContext = React.createContext<RadioGroupContext>({
158
158
 
159
159
  export function RadioGroup({
160
160
  className,
161
- defaultValue,
161
+ value,
162
162
  label,
163
163
  name,
164
164
  onChange,
@@ -167,11 +167,8 @@ export function RadioGroup({
167
167
  hasError,
168
168
  children,
169
169
  }: RadioGroupProps) {
170
- const [selected, setSelected] = useState(defaultValue)
171
-
172
170
  const handleChange = useCallback(
173
171
  (next: string) => {
174
- setSelected(next)
175
172
  onChange(next)
176
173
  },
177
174
  [onChange]
@@ -181,7 +178,7 @@ export function RadioGroup({
181
178
  <RadioGroupContext.Provider
182
179
  value={{
183
180
  name,
184
- selected,
181
+ selected: value,
185
182
  disabled: disabled ?? false,
186
183
  readonly: readonly ?? false,
187
184
  hasError: hasError ?? false,
@@ -0,0 +1,23 @@
1
+ import { createContext } from 'react'
2
+
3
+ type SelectGroupContext = {
4
+ name: string
5
+ selected: string[]
6
+ disabled: boolean
7
+ readonly: boolean
8
+ hasError: boolean
9
+ onChange: ({ value, selected }: { value: string; selected: boolean }) => void
10
+ }
11
+
12
+ export const SelectGroupContext = createContext<SelectGroupContext>({
13
+ name: undefined as never,
14
+ selected: [],
15
+ disabled: false,
16
+ readonly: false,
17
+ hasError: false,
18
+ onChange() {
19
+ throw new Error(
20
+ 'Cannot find `onChange()` handler. Perhaps you forgot to wrap it with `<SelectGroup />` ?'
21
+ )
22
+ },
23
+ })
@@ -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
+ }