@stack-spot/portal-components 2.5.2 → 2.6.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/CHANGELOG.md +15 -0
- package/dist/components/form/Select/CustomSelect.d.ts +81 -0
- package/dist/components/form/Select/CustomSelect.d.ts.map +1 -0
- package/dist/components/form/Select/CustomSelect.js +178 -0
- package/dist/components/form/Select/CustomSelect.js.map +1 -0
- package/dist/components/form/Select/DetailedSelect.d.ts +65 -0
- package/dist/components/form/Select/DetailedSelect.d.ts.map +1 -0
- package/dist/components/form/Select/DetailedSelect.js +80 -0
- package/dist/components/form/Select/DetailedSelect.js.map +1 -0
- package/dist/components/form/Select/Select.d.ts +47 -0
- package/dist/components/form/Select/Select.d.ts.map +1 -0
- package/dist/components/form/Select/Select.js +62 -0
- package/dist/components/form/Select/Select.js.map +1 -0
- package/dist/components/form/Select/index.d.ts +5 -0
- package/dist/components/form/Select/index.d.ts.map +1 -0
- package/dist/components/form/Select/index.js +5 -0
- package/dist/components/form/Select/index.js.map +1 -0
- package/dist/components/form/Select/styled.d.ts +6 -0
- package/dist/components/form/Select/styled.d.ts.map +1 -0
- package/dist/components/form/Select/styled.js +165 -0
- package/dist/components/form/Select/styled.js.map +1 -0
- package/dist/components/form/Select/types.d.ts +103 -0
- package/dist/components/form/Select/types.d.ts.map +1 -0
- package/dist/components/form/Select/types.js +2 -0
- package/dist/components/form/Select/types.js.map +1 -0
- package/dist/components/form/Select/utils.d.ts +3 -0
- package/dist/components/form/Select/utils.d.ts.map +1 -0
- package/dist/components/form/Select/utils.js +20 -0
- package/dist/components/form/Select/utils.js.map +1 -0
- package/dist/components/notification/NotificationItem.d.ts.map +1 -1
- package/dist/components/notification/NotificationItem.js +2 -2
- package/dist/components/notification/NotificationItem.js.map +1 -1
- package/dist/containers/NotificationsPage.js +1 -1
- package/dist/hooks/keyboard.d.ts +4 -4
- package/dist/hooks/keyboard.d.ts.map +1 -1
- package/dist/hooks/keyboard.js +2 -2
- package/dist/hooks/keyboard.js.map +1 -1
- package/package.json +2 -2
- package/src/components/form/Select/CustomSelect.tsx +232 -0
- package/src/components/form/Select/DetailedSelect.tsx +85 -0
- package/src/components/form/Select/Select.tsx +67 -0
- package/src/components/form/Select/index.ts +4 -0
- package/src/components/form/Select/styled.ts +165 -0
- package/src/components/form/Select/types.ts +112 -0
- package/src/components/form/Select/utils.tsx +28 -0
- package/src/components/notification/NotificationItem.tsx +6 -2
- package/src/hooks/keyboard.tsx +6 -4
- package/dist/components/form/Select.d.ts +0 -69
- package/dist/components/form/Select.d.ts.map +0 -1
- package/dist/components/form/Select.js +0 -162
- package/dist/components/form/Select.js.map +0 -1
- package/src/components/form/Select.tsx +0 -265
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { IconBox } from '@citric/core'
|
|
2
|
+
import { ChevronDown } from '@citric/icons'
|
|
3
|
+
import { LoadingCircular } from '@citric/ui'
|
|
4
|
+
import { listToClass } from '@stack-spot/portal-theme'
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
6
|
+
import { delay } from '../../../utils/promise'
|
|
7
|
+
import { SelectBox } from './styled'
|
|
8
|
+
import { CustomSelectProps, GenericAccessibleLabel, KeyOfType } from './types'
|
|
9
|
+
import { parseLabel } from './utils'
|
|
10
|
+
|
|
11
|
+
function getOptionAsValue<Option, T extends KeyOfType<Option, string> | ((o: NonNullable<Option>) => string)>(
|
|
12
|
+
option: Option,
|
|
13
|
+
renderer: T | undefined,
|
|
14
|
+
): string {
|
|
15
|
+
let result: string | undefined
|
|
16
|
+
if (typeof renderer === 'function') result = option ? renderer(option) : undefined
|
|
17
|
+
else if (typeof renderer === 'string') result = option[renderer as keyof Option] as string
|
|
18
|
+
return result ? result : `${option ?? ''}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getOptionAsLabel<
|
|
22
|
+
Option,
|
|
23
|
+
T extends KeyOfType<Option, GenericAccessibleLabel> | ((o: NonNullable<Option>) => GenericAccessibleLabel)
|
|
24
|
+
>(
|
|
25
|
+
option: Option,
|
|
26
|
+
renderer: T | undefined,
|
|
27
|
+
): GenericAccessibleLabel {
|
|
28
|
+
let result: GenericAccessibleLabel | undefined
|
|
29
|
+
if (typeof renderer === 'function') result = option ? renderer(option) : undefined
|
|
30
|
+
else if (typeof renderer === 'string') result = option[renderer as keyof Option] as GenericAccessibleLabel
|
|
31
|
+
return result ? result : parseLabel(`${option ?? ''}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const FakeOption = (
|
|
35
|
+
{ value, label, onChange }: { value: string, label: React.ReactElement, onChange: (event: { target: { value: string } }) => void },
|
|
36
|
+
) => (
|
|
37
|
+
<li className="option" onClick={() => onChange({ target: { value } })}>
|
|
38
|
+
{label}
|
|
39
|
+
</li>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Renders a Select component using the Citric Design System.
|
|
44
|
+
*
|
|
45
|
+
* The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
|
|
46
|
+
* version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
|
|
47
|
+
* highly optimized for accessibility.
|
|
48
|
+
*
|
|
49
|
+
* The CustomSelect lets you customize how each option and the selected value are rendered. To do so, use the prop `renderLabel` and
|
|
50
|
+
* `emptyOption`.
|
|
51
|
+
*
|
|
52
|
+
* If you don't need fully customized labels, check the more simple components: `DetailedSelect` and `Select`.
|
|
53
|
+
*
|
|
54
|
+
* The CustomSelect expects a {@link GenericAccessibleLabel} to create labels.
|
|
55
|
+
*
|
|
56
|
+
* Tips:
|
|
57
|
+
* - This is a controlled field. You can't use it any other way. If you're using it with react-hook-form, you need to wrap it under the
|
|
58
|
+
* component `<Controller>` from the same library.
|
|
59
|
+
* - `value` is required and must be of the same type of an item of the array of options. `value` is only optional if `emptyOption` is
|
|
60
|
+
* provided, in this case, an empty option is rendered and the value is undefined when it's selected.
|
|
61
|
+
* - A consequence of the previous rule is that you can't have an empty selection if you don't set a value for `emptyOption`. This
|
|
62
|
+
* component must work exactly like the browser's `select`, so this behavior is intended.
|
|
63
|
+
* - If `renderLabel` or `renderValue` are not provided, this will use the `toString` method of the object.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* options as an object array
|
|
67
|
+
* ```
|
|
68
|
+
* const options = ['option 1', 'option 2', 'option 3']
|
|
69
|
+
*
|
|
70
|
+
* function renderCustomLabel(option: string) {
|
|
71
|
+
* return {
|
|
72
|
+
* // this is how the option will be rendered in the list
|
|
73
|
+
* option: (
|
|
74
|
+
* <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
|
|
75
|
+
* <img src="/my-image.png" width="40px" height="40px" />
|
|
76
|
+
* <p>An option called {option}</p>
|
|
77
|
+
* </div>
|
|
78
|
+
* ),
|
|
79
|
+
* // this is how the option will be rendered inside the input, when it's the value currently selected.
|
|
80
|
+
* selected: <p>{option}</p>,
|
|
81
|
+
* // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
|
|
82
|
+
* text: `An option called ${option}`,
|
|
83
|
+
* )
|
|
84
|
+
* }
|
|
85
|
+
*
|
|
86
|
+
* const MyComponent = {
|
|
87
|
+
* const [value, setValue] = useState(options[0])
|
|
88
|
+
* return <CustomSelect options={options} value={value} onChange={setValue} renderLabel={renderCustomLabel} />
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
* @example
|
|
92
|
+
* options as an object array
|
|
93
|
+
* ```
|
|
94
|
+
* const options = [{ id: 1, name: 'John', age: 34 }, { id: 2, name: 'Marcia', age: 28 }, { id: 3, name: 'Angeline', age: 58 }]
|
|
95
|
+
*
|
|
96
|
+
* function renderCustomLabel(option: (typeof options)[number]) {
|
|
97
|
+
* return {
|
|
98
|
+
* // this is how the option will be rendered in the list
|
|
99
|
+
* option: (
|
|
100
|
+
* <div style={{ display: flex, flexDirection: 'row', gap: '5px' }}>
|
|
101
|
+
* <img src="/my-image.png" width="40px" height="40px" />
|
|
102
|
+
* <p>{option.name}, aged {option.age}</p>
|
|
103
|
+
* </div>
|
|
104
|
+
* ),
|
|
105
|
+
* // this is how the option will be rendered inside the input, when it's the value currently selected.
|
|
106
|
+
* selected: <p>{option}</p>,
|
|
107
|
+
* // this a string representation of the option: used for accessibility. This should contain the same information as `option`.
|
|
108
|
+
* text: `${option.name}, aged ${option.age}`,
|
|
109
|
+
* )
|
|
110
|
+
* }
|
|
111
|
+
*
|
|
112
|
+
* const MyComponent = {
|
|
113
|
+
* const [value, setValue] = useState(options[0])
|
|
114
|
+
* // below, renderValue could be `o => o.id`
|
|
115
|
+
* return <CustomSelect options={options} value={value} onChange={setValue} renderValue="id" renderLabel={renderCustomLabel} />
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
* @param props the component props: {@link CustomSelectProps}.
|
|
119
|
+
*/
|
|
120
|
+
export function CustomSelect<T>({
|
|
121
|
+
onChange, options, value, emptyOption, renderLabel, renderValue, maxItems = 6, onFocus, onBlur, style, className, isLoading, disabled,
|
|
122
|
+
height = '42px', ...props
|
|
123
|
+
}: CustomSelectProps<T>) {
|
|
124
|
+
const [open, setOpen] = useState(false)
|
|
125
|
+
const [focused, setFocused] = useState(false)
|
|
126
|
+
const fakeSelectRef = useRef<HTMLDivElement>(null)
|
|
127
|
+
const listHeight = useRef(0)
|
|
128
|
+
const isDisabled = disabled || isLoading
|
|
129
|
+
const isMeasuring = useRef(false)
|
|
130
|
+
|
|
131
|
+
const onChangeOption = useCallback((event: { target: { value: string } }) => {
|
|
132
|
+
const value = options?.find(o => getOptionAsValue(o, renderValue) === event.target.value)
|
|
133
|
+
onChange(value!)
|
|
134
|
+
setOpen(false)
|
|
135
|
+
}, [options])
|
|
136
|
+
|
|
137
|
+
const onClickOutside = useCallback((event: MouseEvent) => {
|
|
138
|
+
if (fakeSelectRef.current && !fakeSelectRef.current.contains(event.target as Node)) setOpen(false)
|
|
139
|
+
}, [])
|
|
140
|
+
|
|
141
|
+
const [htmlOptions, fakeOptions] = useMemo(
|
|
142
|
+
() => (options ?? []).reduce<[React.ReactElement[], React.ReactElement[]]>(([opts, fake], o) => {
|
|
143
|
+
const id = getOptionAsValue(o, renderValue)
|
|
144
|
+
const label = getOptionAsLabel(o, renderLabel)
|
|
145
|
+
return [
|
|
146
|
+
[...opts, <option key={id} value={id} selected={value === id}>{label.text}</option>],
|
|
147
|
+
[...fake, <FakeOption key={id} value={id} label={label.option} onChange={onChangeOption} />],
|
|
148
|
+
]
|
|
149
|
+
}, [[], []]),
|
|
150
|
+
[options, value],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
function getCurrentValue() {
|
|
154
|
+
return value === undefined ? '' : getOptionAsValue(value, renderValue)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getCurrentLabel() {
|
|
158
|
+
return value === undefined ? emptyOption : getOptionAsLabel(value, renderLabel)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const detach = () => document.removeEventListener('mousedown', onClickOutside)
|
|
163
|
+
if (open) document.addEventListener('mousedown', onClickOutside)
|
|
164
|
+
else detach()
|
|
165
|
+
return detach
|
|
166
|
+
}, [open])
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
async function measure() {
|
|
170
|
+
// semaphore for controlling concurrence
|
|
171
|
+
if (isMeasuring.current) return
|
|
172
|
+
isMeasuring.current = true
|
|
173
|
+
const list = fakeSelectRef.current?.querySelector('.options')
|
|
174
|
+
if (!list) return
|
|
175
|
+
list.setAttribute('inert', '')
|
|
176
|
+
list.setAttribute('style', 'height: auto')
|
|
177
|
+
await delay(0)
|
|
178
|
+
listHeight.current = list.clientHeight
|
|
179
|
+
await delay(0)
|
|
180
|
+
list.setAttribute('style', `height: ${open ? listHeight.current : 0}`)
|
|
181
|
+
list.removeAttribute('inert')
|
|
182
|
+
isMeasuring.current = false
|
|
183
|
+
}
|
|
184
|
+
measure()
|
|
185
|
+
}, [options, fakeSelectRef.current])
|
|
186
|
+
|
|
187
|
+
// replicates the original select effect of the browser to select the first option. This is necessary because we use the default
|
|
188
|
+
// select element for accessibility, the behavior must not differ.
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
if (!value && !emptyOption && options?.length) {
|
|
191
|
+
onChange(options[0])
|
|
192
|
+
}
|
|
193
|
+
}, [options])
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<SelectBox style={style} className={className} $maxItems={maxItems} $inputHeight={height}>
|
|
197
|
+
{ /* Screen readers can use the select component from the browser instead of the highly styled component we show. */ }
|
|
198
|
+
<select
|
|
199
|
+
{...props}
|
|
200
|
+
value={getCurrentValue()}
|
|
201
|
+
onChange={onChangeOption}
|
|
202
|
+
onFocus={(ev) => {
|
|
203
|
+
setFocused(true)
|
|
204
|
+
onFocus?.(ev)
|
|
205
|
+
}}
|
|
206
|
+
onBlur={(ev) => {
|
|
207
|
+
setFocused(false)
|
|
208
|
+
onBlur?.(ev)
|
|
209
|
+
}}
|
|
210
|
+
disabled={isDisabled}
|
|
211
|
+
aria-busy={isLoading}
|
|
212
|
+
>
|
|
213
|
+
{emptyOption === undefined ? null : <option value="" selected={!value}>{emptyOption.text}</option>}
|
|
214
|
+
{htmlOptions}
|
|
215
|
+
</select>
|
|
216
|
+
<div
|
|
217
|
+
ref={fakeSelectRef}
|
|
218
|
+
className={listToClass(['fake-select', open && 'open', focused && 'focused', isDisabled && 'disabled'])}
|
|
219
|
+
aria-hidden
|
|
220
|
+
>
|
|
221
|
+
<div className="current-value" onClick={isDisabled ? undefined : () => setOpen(!open)}>
|
|
222
|
+
{getCurrentLabel()?.selected || getCurrentLabel()?.option || <div></div>}
|
|
223
|
+
{isLoading ? <LoadingCircular size="sm" /> : <IconBox className="arrow"><ChevronDown /></IconBox>}
|
|
224
|
+
</div>
|
|
225
|
+
<ul className="options" style={{ height: open ? listHeight.current : 0 }}>
|
|
226
|
+
{emptyOption === undefined ? null : <FakeOption value="" label={emptyOption.option} onChange={onChangeOption} />}
|
|
227
|
+
{fakeOptions}
|
|
228
|
+
</ul>
|
|
229
|
+
</div>
|
|
230
|
+
</SelectBox>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { CustomSelect } from './CustomSelect'
|
|
3
|
+
import { DetailedLabel, DetailedSelectProps, GenericAccessibleLabel, KeyOfType } from './types'
|
|
4
|
+
import { parseLabel } from './utils'
|
|
5
|
+
|
|
6
|
+
function getOptionAsLabel<
|
|
7
|
+
Option,
|
|
8
|
+
T extends KeyOfType<Option, string> | ((o: NonNullable<Option>) => DetailedLabel)
|
|
9
|
+
>(
|
|
10
|
+
option: Option,
|
|
11
|
+
renderer: T | undefined,
|
|
12
|
+
): GenericAccessibleLabel {
|
|
13
|
+
let result: DetailedLabel | undefined
|
|
14
|
+
if (typeof renderer === 'function') result = option ? renderer(option) : undefined
|
|
15
|
+
else if (typeof renderer === 'string') result = option[renderer as keyof Option] as DetailedLabel
|
|
16
|
+
return parseLabel(result ?? `${option ?? ''}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders a Select component using the Citric Design System.
|
|
21
|
+
*
|
|
22
|
+
* The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
|
|
23
|
+
* version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
|
|
24
|
+
* highly optimized for accessibility.
|
|
25
|
+
*
|
|
26
|
+
* The DetailedSelect lets you set an image, a title and a description for each option. To do so, use the prop `renderLabel` and
|
|
27
|
+
* `emptyOption`. The accessible string for each option will always be `option.title: option.description`.
|
|
28
|
+
*
|
|
29
|
+
* To use more customizable labels, check the `CustomSelect`. To use more simple labels (just strings) use `Select`.
|
|
30
|
+
*
|
|
31
|
+
* The DetailedSelect expects a {@link DetailedLabel} to create labels.
|
|
32
|
+
*
|
|
33
|
+
* Tips:
|
|
34
|
+
* - This is a controlled field. You can't use it any other way. If you're using it with react-hook-form, you need to wrap it under the
|
|
35
|
+
* component `<Controller>` from the same library.
|
|
36
|
+
* - `value` is required and must be of the same type of an item of the array of options. `value` is only optional if `emptyOption` is
|
|
37
|
+
* provided, in this case, an empty option is rendered and the value is undefined when it's selected.
|
|
38
|
+
* - A consequence of the previous rule is that you can't have an empty selection if you don't set a value for `emptyOption`. This
|
|
39
|
+
* component must work exactly like the browser's `select`, so this behavior is intended.
|
|
40
|
+
* - If `renderLabel` or `renderValue` are not provided, this will use the `toString` method of the object to set the option's title.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* options as a string array
|
|
44
|
+
* ```
|
|
45
|
+
* const options = ['option 1', 'option 2', 'option 3']
|
|
46
|
+
*
|
|
47
|
+
* function renderDetailedLabel(option: string) {
|
|
48
|
+
* return {
|
|
49
|
+
* title: option,
|
|
50
|
+
* description: 'my description',
|
|
51
|
+
* image: <img src="/my-image.png" />,
|
|
52
|
+
* }
|
|
53
|
+
* }
|
|
54
|
+
*
|
|
55
|
+
* const MyComponent = {
|
|
56
|
+
* const [value, setValue] = useState(options[0])
|
|
57
|
+
* return <DetailedSelect options={options} value={value} onChange={setValue} renderLabel={renderDetailedLabel} />
|
|
58
|
+
* }
|
|
59
|
+
* ```
|
|
60
|
+
* @example
|
|
61
|
+
* options as an object array
|
|
62
|
+
* ```
|
|
63
|
+
* const options = [{ id: 1, name: 'John', age: 34 }, { id: 2, name: 'Marcia', age: 28 }, { id: 3, name: 'Angeline', age: 58 }]
|
|
64
|
+
*
|
|
65
|
+
* function renderDetailedLabel(option: (typeof options)[number]) {
|
|
66
|
+
* return {
|
|
67
|
+
* title: option.name,
|
|
68
|
+
* description: `${option.age} years old`,
|
|
69
|
+
* image: <img src="/my-image.png" />,
|
|
70
|
+
* }
|
|
71
|
+
* }
|
|
72
|
+
*
|
|
73
|
+
* const MyComponent = {
|
|
74
|
+
* const [value, setValue] = useState(options[0])
|
|
75
|
+
* // below, renderValue could be `o => o.id`
|
|
76
|
+
* return <DetailedSelect options={options} value={value} onChange={setValue} renderValue="id" renderLabel={renderDetailedLabel} />
|
|
77
|
+
* }
|
|
78
|
+
* ```
|
|
79
|
+
* @param props the component props: {@link DetailedSelectProps}.
|
|
80
|
+
*/
|
|
81
|
+
export function DetailedSelect<T>({ renderLabel, emptyOption, ...props }: DetailedSelectProps<T>) {
|
|
82
|
+
const renderLabelRef = useCallback((option: T) => getOptionAsLabel(option, renderLabel), [])
|
|
83
|
+
// @ts-ignore the following usage is correct, Typescript is getting confused because it doesn't know if `emptyOption` is undefined or not.
|
|
84
|
+
return <CustomSelect renderLabel={renderLabelRef} emptyOption={emptyOption ? parseLabel(emptyOption) : undefined} {...props} />
|
|
85
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { CustomSelect } from './CustomSelect'
|
|
3
|
+
import { GenericAccessibleLabel, KeyOfType, SelectProps } from './types'
|
|
4
|
+
import { parseLabel } from './utils'
|
|
5
|
+
|
|
6
|
+
function getOptionAsLabel<
|
|
7
|
+
Option,
|
|
8
|
+
T extends KeyOfType<Option, string> | ((o: NonNullable<Option>) => string)
|
|
9
|
+
>(
|
|
10
|
+
option: Option,
|
|
11
|
+
renderer: T | undefined,
|
|
12
|
+
): GenericAccessibleLabel {
|
|
13
|
+
let result: string | undefined
|
|
14
|
+
if (typeof renderer === 'function') result = option ? renderer(option) : undefined
|
|
15
|
+
else if (typeof renderer === 'string') result = option[renderer as keyof Option] as string
|
|
16
|
+
return parseLabel(result ?? `${option ?? ''}`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders a Select component using the Citric Design System.
|
|
21
|
+
*
|
|
22
|
+
* The styled version of the select component is rendered on top of the default select from the browser. Visual users will use the Citric
|
|
23
|
+
* version of a Select, but blind users, who interacts with the keyboard, will use the default browser select instead, which is already
|
|
24
|
+
* highly optimized for accessibility.
|
|
25
|
+
*
|
|
26
|
+
* The Select component renders each option as a string. To use more customizable labels, check the `DetailedSelect` and `CustomSelect`
|
|
27
|
+
* components.
|
|
28
|
+
*
|
|
29
|
+
* The Select component expects plain strings to create labels.
|
|
30
|
+
*
|
|
31
|
+
* Tips:
|
|
32
|
+
* - This is a controlled field. You can't use it any other way. If you're using it with react-hook-form, you need to wrap it under the
|
|
33
|
+
* component `<Controller>` from the same library.
|
|
34
|
+
* - `value` is required and must be of the same type of an item of the array of options. `value` is only optional if `emptyOption` is
|
|
35
|
+
* provided, in this case, an empty option is rendered and the value is undefined when it's selected.
|
|
36
|
+
* - A consequence of the previous rule is that you can't have an empty selection if you don't set a value for `emptyOption`. This
|
|
37
|
+
* component must work exactly like the browser's `select`, so this behavior is intended.
|
|
38
|
+
* - If `renderLabel` or `renderValue` are not provided, this will use the `toString` method of the object.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* options as a string array
|
|
42
|
+
* ```
|
|
43
|
+
* const options = ['option 1', 'option 2', 'option 3']
|
|
44
|
+
*
|
|
45
|
+
* const MyComponent = {
|
|
46
|
+
* const [value, setValue] = useState(options[0])
|
|
47
|
+
* return <Select options={options} value={value} onChange={setValue} />
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
* @example
|
|
51
|
+
* options as an object array
|
|
52
|
+
* ```
|
|
53
|
+
* const options = [{ id: 1, name: 'John', age: 34 }, { id: 2, name: 'Marcia', age: 28 }, { id: 3, name: 'Angeline', age: 58 }]
|
|
54
|
+
*
|
|
55
|
+
* const MyComponent = {
|
|
56
|
+
* const [value, setValue] = useState(options[0])
|
|
57
|
+
* // below, renderValue could be `o => o.id` and renderLabel `o => o.name`.
|
|
58
|
+
* return <Select options={options} value={value} onChange={setValue} renderValue="id" renderLabel="name" />
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
* @param props the component props: {@link CustomSelectProps}.
|
|
62
|
+
*/
|
|
63
|
+
export function Select<T>({ renderLabel, emptyOption, ...props }: SelectProps<T>) {
|
|
64
|
+
const renderLabelRef = useCallback((option: T) => getOptionAsLabel(option, renderLabel), [])
|
|
65
|
+
// @ts-ignore the following usage is correct, Typescript is getting confused because it doesn't know if `emptyOption` is undefined or not.
|
|
66
|
+
return <CustomSelect renderLabel={renderLabelRef} emptyOption={emptyOption ? parseLabel(emptyOption) : undefined} {...props} />
|
|
67
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { theme } from '@stack-spot/portal-theme'
|
|
2
|
+
import { styled } from 'styled-components'
|
|
3
|
+
|
|
4
|
+
export const SelectBox = styled.div<{ $maxItems: number, $inputHeight: string }>`
|
|
5
|
+
position: relative;
|
|
6
|
+
// controls the height of component when it's closed
|
|
7
|
+
height: ${({ $inputHeight }) => $inputHeight};
|
|
8
|
+
|
|
9
|
+
select {
|
|
10
|
+
border: none;
|
|
11
|
+
opacity: 0;
|
|
12
|
+
pointer-events: none;
|
|
13
|
+
// prevents visual overflow
|
|
14
|
+
max-width: 10px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.fake-select {
|
|
18
|
+
position: absolute;
|
|
19
|
+
top: 0;
|
|
20
|
+
left: 0;
|
|
21
|
+
right: 0;
|
|
22
|
+
border-radius: 0.25rem;
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
border: 1px solid ${theme.color.light[600]};
|
|
26
|
+
transition: border-color 0.3s, box-shadow 0.3s;
|
|
27
|
+
background-color: ${theme.color.light[300]};
|
|
28
|
+
|
|
29
|
+
/* lets the z-index unset until the animation on the height ends. */
|
|
30
|
+
&:not(.open) {
|
|
31
|
+
z-index: unset;
|
|
32
|
+
animation: 0.3s z-index-animation;
|
|
33
|
+
@keyframes z-index-animation {
|
|
34
|
+
0% {
|
|
35
|
+
z-index: 1;
|
|
36
|
+
}
|
|
37
|
+
99% {
|
|
38
|
+
z-index: 1;
|
|
39
|
+
}
|
|
40
|
+
100% {
|
|
41
|
+
z-index: unset;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
&.disabled {
|
|
47
|
+
background-color: ${theme.color.light[500]};
|
|
48
|
+
color: ${theme.color.light[700]};
|
|
49
|
+
.current-value {
|
|
50
|
+
cursor: not-allowed;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.arrow {
|
|
55
|
+
transition: transform ease-in-out 0.3s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
&.focused, &.open {
|
|
59
|
+
border: 1px solid ${theme.color.primary[500]};
|
|
60
|
+
box-shadow: 0 0 0 1px ${theme.color.primary[500]};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
&.open {
|
|
64
|
+
z-index: 1;
|
|
65
|
+
.arrow {
|
|
66
|
+
transform: rotate(180deg);
|
|
67
|
+
}
|
|
68
|
+
.options {
|
|
69
|
+
/* lets the overflow be hidden until the animation on the height ends. */
|
|
70
|
+
overflow-y: auto;
|
|
71
|
+
animation: 0.3s overflow-animation;
|
|
72
|
+
@keyframes overflow-animation {
|
|
73
|
+
0% {
|
|
74
|
+
overflow-y: hidden;
|
|
75
|
+
}
|
|
76
|
+
99% {
|
|
77
|
+
overflow-y: hidden;
|
|
78
|
+
}
|
|
79
|
+
100% {
|
|
80
|
+
overflow-y: auto;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.current-value {
|
|
87
|
+
height: calc(100% - 2px);
|
|
88
|
+
display: flex;
|
|
89
|
+
flex-direction: row;
|
|
90
|
+
padding: 8px;
|
|
91
|
+
justify-content: space-between;
|
|
92
|
+
align-items: center;
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.clipped-text {
|
|
97
|
+
text-overflow: ellipsis;
|
|
98
|
+
width: 100%;
|
|
99
|
+
overflow: hidden;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.options {
|
|
104
|
+
list-style: none;
|
|
105
|
+
padding: 0;
|
|
106
|
+
margin: 0;
|
|
107
|
+
overflow-y: hidden;
|
|
108
|
+
transition: height ease-in-out 0.3s;
|
|
109
|
+
scrollbar-gutter: stable;
|
|
110
|
+
|
|
111
|
+
li {
|
|
112
|
+
display: flex;
|
|
113
|
+
flex-direction: row;
|
|
114
|
+
align-items: center;
|
|
115
|
+
padding: 9px;
|
|
116
|
+
border-top: 1px solid ${theme.color.light[600]};
|
|
117
|
+
cursor: pointer;
|
|
118
|
+
transition: background-color 0.2s;
|
|
119
|
+
&:hover, &:focus {
|
|
120
|
+
background-color: ${theme.color.light[500]};
|
|
121
|
+
}
|
|
122
|
+
outline: none;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.detailed {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-direction: row;
|
|
128
|
+
gap: 10px;
|
|
129
|
+
align-items: center;
|
|
130
|
+
.image {
|
|
131
|
+
width: 40px;
|
|
132
|
+
height: 40px;
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
flex-shrink: 0;
|
|
137
|
+
overflow: hidden;
|
|
138
|
+
& > * {
|
|
139
|
+
max-width: 100%;
|
|
140
|
+
max-height: 100%;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
.text-content {
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: 5px;
|
|
147
|
+
.description {
|
|
148
|
+
color: ${theme.color.light[700]};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* the list is inert when, and only when, its having its height measured */
|
|
154
|
+
&[inert] {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
pointer-events: none;
|
|
157
|
+
position: absolute;
|
|
158
|
+
/* we don't want to have the height measured over the height of $maxItems */
|
|
159
|
+
li:nth-child(n+${({ $maxItems }) => $maxItems + 1}) {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
`
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { InputHTMLAttributes } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface GenericAccessibleLabel {
|
|
4
|
+
/**
|
|
5
|
+
* This is how the option's label will be rendered inside the dropdown list. This can be any React element.
|
|
6
|
+
*
|
|
7
|
+
* Attention: be careful, this should not have any important information that can't be represented through text (accessibility).
|
|
8
|
+
*/
|
|
9
|
+
option: React.ReactElement,
|
|
10
|
+
/**
|
|
11
|
+
* This is a required text representation of the option. This is used for accessibility.
|
|
12
|
+
*/
|
|
13
|
+
text: string,
|
|
14
|
+
/**
|
|
15
|
+
* This is how the option's label will be rendered as the select current value.
|
|
16
|
+
*
|
|
17
|
+
* If not provided, will be rendered using `option`.
|
|
18
|
+
*/
|
|
19
|
+
selected?: React.ReactElement,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DetailedLabel {
|
|
23
|
+
title: string,
|
|
24
|
+
description?: string,
|
|
25
|
+
image?: React.ReactElement,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AccessibleLabel = string | DetailedLabel | GenericAccessibleLabel
|
|
29
|
+
|
|
30
|
+
type FilterObjectByValueType<T, Type> = { [K in keyof T as T[K] extends Type ? K : never]: T[K] }
|
|
31
|
+
export type KeyOfType<Data, Type> = Data extends Record<string, any> ? keyof FilterObjectByValueType<Data, Type> : never
|
|
32
|
+
|
|
33
|
+
interface BaseSelectProps<
|
|
34
|
+
Option, Label extends AccessibleLabel
|
|
35
|
+
> extends Omit<InputHTMLAttributes<HTMLSelectElement>, 'value' | 'onChange'> {
|
|
36
|
+
/**
|
|
37
|
+
* The current value.
|
|
38
|
+
*/
|
|
39
|
+
value: Option | undefined,
|
|
40
|
+
/**
|
|
41
|
+
* The label for the empty option. The empty option sets the value to undefined.
|
|
42
|
+
*
|
|
43
|
+
* If this is not set, there won't be an empty option for this select.
|
|
44
|
+
*/
|
|
45
|
+
emptyOption?: Label,
|
|
46
|
+
/**
|
|
47
|
+
* The options to render in this selection menu.
|
|
48
|
+
*/
|
|
49
|
+
options: Option[] | undefined, // this may be undefined if isLoading is true
|
|
50
|
+
/**
|
|
51
|
+
* Provides the value of each option. This can be either a key of the option object or a function that receives the option and returns
|
|
52
|
+
* the value.
|
|
53
|
+
*
|
|
54
|
+
* This is required if the options don't have a relevant value returned by `toString()`.
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* - `'id'`
|
|
58
|
+
* - `(option) => option.id`
|
|
59
|
+
*/
|
|
60
|
+
renderValue?: KeyOfType<Option, string> | ((item: NonNullable<Option>) => string),
|
|
61
|
+
/**
|
|
62
|
+
* Provides the label of each option. This can be either a key of the option object or a function that receives the option and returns
|
|
63
|
+
* the label.
|
|
64
|
+
*
|
|
65
|
+
* This is required if the options don't have a relevant value returned by `toString()`.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* - `'name'`
|
|
69
|
+
* - `(option) => option.name`
|
|
70
|
+
*/
|
|
71
|
+
renderLabel?: KeyOfType<Option, Label> | ((item: NonNullable<Option>) => Label),
|
|
72
|
+
/**
|
|
73
|
+
* Called when the value changes.
|
|
74
|
+
* @param value the new value.
|
|
75
|
+
*/
|
|
76
|
+
onChange: (value: Option | undefined) => void,
|
|
77
|
+
/**
|
|
78
|
+
* The maximum number of items before showing a vertical scroll bar.
|
|
79
|
+
* @default 6
|
|
80
|
+
*/
|
|
81
|
+
maxItems?: number,
|
|
82
|
+
/**
|
|
83
|
+
* Whether or not the options are being loaded. The field will become disabled while isLoading is true.
|
|
84
|
+
*/
|
|
85
|
+
isLoading?: boolean,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface OptionalSelectProps<Option, Label extends AccessibleLabel> extends BaseSelectProps<Option, Label> {
|
|
89
|
+
emptyOption: Label,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface RequiredSelectProps<Option, Label extends AccessibleLabel> extends Omit<BaseSelectProps<Option, Label>, 'onChange'> {
|
|
93
|
+
emptyOption?: undefined,
|
|
94
|
+
value: Option | undefined, // this may be undefined if isLoading is true
|
|
95
|
+
onChange: (value: Option) => void,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type SelectProps<Option> = OptionalSelectProps<Option, string> | RequiredSelectProps<Option, string>
|
|
99
|
+
export type DetailedSelectProps<Option> = OptionalSelectProps<Option, DetailedLabel> | RequiredSelectProps<Option, DetailedLabel>
|
|
100
|
+
export type CustomSelectProps<Option> = (
|
|
101
|
+
OptionalSelectProps<Option, GenericAccessibleLabel>
|
|
102
|
+
| RequiredSelectProps<Option, GenericAccessibleLabel>
|
|
103
|
+
) & {
|
|
104
|
+
/**
|
|
105
|
+
* Controls the height of component when it's closed.
|
|
106
|
+
*
|
|
107
|
+
* Developer note: it would be nice to automatically calculate this instead of expecting a prop.
|
|
108
|
+
*
|
|
109
|
+
* @default '42px'
|
|
110
|
+
*/
|
|
111
|
+
height?: string,
|
|
112
|
+
}
|