@startupjs-ui/multi-select 0.1.3
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 +20 -0
- package/README.mdx +234 -0
- package/index.cssx.styl +95 -0
- package/index.d.ts +61 -0
- package/index.tsx +394 -0
- package/package.json +26 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
|
+
|
|
6
|
+
## [0.1.3](https://github.com/startupjs/startupjs-ui/compare/v0.1.2...v0.1.3) (2025-12-29)
|
|
7
|
+
|
|
8
|
+
**Note:** Version bump only for package @startupjs-ui/multi-select
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## [0.1.2](https://github.com/startupjs/startupjs-ui/compare/v0.1.1...v0.1.2) (2025-12-29)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* add mdx and docs packages. Refactor docs to get rid of any @startupjs/ui usage and use startupjs-ui instead ([703c926](https://github.com/startupjs/startupjs-ui/commit/703c92636efb0421ffd11783f692fc892b74018f))
|
|
20
|
+
* **multi-select:** refactor MultiSelect component ([65ea9c9](https://github.com/startupjs/startupjs-ui/commit/65ea9c9b06c68c1058c92a6f775fed735d509cb4))
|
package/README.mdx
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Sandbox } from '@startupjs-ui/docs'
|
|
3
|
+
import Br from '@startupjs-ui/br'
|
|
4
|
+
import Div from '@startupjs-ui/div'
|
|
5
|
+
import Span from '@startupjs-ui/span'
|
|
6
|
+
import MultiSelect, { _PropsJsonSchema as MultiSelectPropsJsonSchema } from './index'
|
|
7
|
+
|
|
8
|
+
# MultiSelect
|
|
9
|
+
|
|
10
|
+
MultiSelect lets user pick multiple options.
|
|
11
|
+
|
|
12
|
+
```jsx
|
|
13
|
+
import { MultiSelect } from 'startupjs-ui'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Initialization
|
|
17
|
+
|
|
18
|
+
Before use you need to configure [Portal](/docs/components/Portal)
|
|
19
|
+
|
|
20
|
+
## Simple example
|
|
21
|
+
|
|
22
|
+
```jsx example
|
|
23
|
+
const OPTIONS = [
|
|
24
|
+
{ label: 'New York', value: 'ny' },
|
|
25
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
26
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
const [cities, setCities] = useState([])
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<MultiSelect
|
|
33
|
+
value={cities}
|
|
34
|
+
onChange={setCities}
|
|
35
|
+
options={OPTIONS}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Options
|
|
41
|
+
|
|
42
|
+
You can pass array of objects `{ label, value }` or primitives to `options`.
|
|
43
|
+
|
|
44
|
+
```jsx example
|
|
45
|
+
const OBJECT_OPTIONS_EXAMPLE = [
|
|
46
|
+
{ label: 'New York', value: 'ny' },
|
|
47
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
48
|
+
]
|
|
49
|
+
const PRIMITIVIES_OPTIONS_EXAMPLE = [
|
|
50
|
+
'New York',
|
|
51
|
+
'Los Angeles',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const [cities, setCities] = useState([])
|
|
55
|
+
const [citiesArray, setCitiesArray] = useState([])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<>
|
|
59
|
+
<MultiSelect
|
|
60
|
+
value={cities}
|
|
61
|
+
onChange={setCities}
|
|
62
|
+
options={OBJECT_OPTIONS_EXAMPLE}
|
|
63
|
+
/>
|
|
64
|
+
<Br />
|
|
65
|
+
<MultiSelect
|
|
66
|
+
value={citiesArray}
|
|
67
|
+
onChange={setCitiesArray}
|
|
68
|
+
options={PRIMITIVIES_OPTIONS_EXAMPLE}
|
|
69
|
+
/>
|
|
70
|
+
</>
|
|
71
|
+
)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Disabled
|
|
75
|
+
|
|
76
|
+
```jsx example
|
|
77
|
+
const OPTIONS = [
|
|
78
|
+
{ label: 'New York', value: 'ny' },
|
|
79
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
80
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
81
|
+
]
|
|
82
|
+
const [cities, setCities] = useState(['ny'])
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<MultiSelect
|
|
86
|
+
disabled
|
|
87
|
+
value={cities}
|
|
88
|
+
onChange={setCities}
|
|
89
|
+
options={OPTIONS}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Readonly
|
|
95
|
+
|
|
96
|
+
```jsx example
|
|
97
|
+
const OPTIONS = [
|
|
98
|
+
{ label: 'New York', value: 'ny' },
|
|
99
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
100
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
101
|
+
]
|
|
102
|
+
const [cities, setCities] = useState(['ny'])
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<MultiSelect
|
|
106
|
+
readonly
|
|
107
|
+
value={cities}
|
|
108
|
+
onChange={setCities}
|
|
109
|
+
options={OPTIONS}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Tag
|
|
115
|
+
|
|
116
|
+
You can add a custom tag component.
|
|
117
|
+
|
|
118
|
+
```jsx example
|
|
119
|
+
const OPTIONS = [
|
|
120
|
+
{ label: 'New York', value: 'ny' },
|
|
121
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
122
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
const [cities, setCities] = useState([])
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<MultiSelect
|
|
129
|
+
value={cities}
|
|
130
|
+
TagComponent={({ record }) => <Span>✔ {record.label} </Span>}
|
|
131
|
+
onChange={setCities}
|
|
132
|
+
options={OPTIONS}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Input Component
|
|
138
|
+
|
|
139
|
+
You can add a custom input component.
|
|
140
|
+
|
|
141
|
+
```jsx example
|
|
142
|
+
const OPTIONS = [
|
|
143
|
+
{ label: 'New York', value: 'ny' },
|
|
144
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
145
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
const [cities, setCities] = useState([])
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<MultiSelect
|
|
152
|
+
value={cities}
|
|
153
|
+
InputComponent={
|
|
154
|
+
({ children, onOpen }) => {
|
|
155
|
+
return (
|
|
156
|
+
<Div
|
|
157
|
+
onPress={onOpen}
|
|
158
|
+
style={{
|
|
159
|
+
border: '1px dotted green',
|
|
160
|
+
padding: 8
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
{children}
|
|
164
|
+
</Div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
TagComponent={({ record }) => <Span>✔ {record.label} </Span>}
|
|
169
|
+
onChange={setCities}
|
|
170
|
+
options={OPTIONS}
|
|
171
|
+
/>
|
|
172
|
+
)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Tag limit
|
|
176
|
+
|
|
177
|
+
You can limit the number of displayed tags by passing `tagLimit={number}`.
|
|
178
|
+
|
|
179
|
+
```jsx example
|
|
180
|
+
const OPTIONS = [
|
|
181
|
+
{ label: 'New York', value: 'ny' },
|
|
182
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
183
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
184
|
+
]
|
|
185
|
+
|
|
186
|
+
const [cities, setCities] = useState([])
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<MultiSelect
|
|
190
|
+
tagLimit={2}
|
|
191
|
+
value={cities}
|
|
192
|
+
onChange={setCities}
|
|
193
|
+
options={OPTIONS}
|
|
194
|
+
/>
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Limit the number of selected tags
|
|
199
|
+
|
|
200
|
+
You can limit the number of selected tags by passing `maxTagCount={number}`.
|
|
201
|
+
|
|
202
|
+
```jsx example
|
|
203
|
+
const OPTIONS = [
|
|
204
|
+
{ label: 'New York', value: 'ny' },
|
|
205
|
+
{ label: 'Los Angeles', value: 'la' },
|
|
206
|
+
{ label: 'Tokyo', value: 'tk' },
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
const [cities, setCities] = useState([])
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<MultiSelect
|
|
213
|
+
maxTagCount={2}
|
|
214
|
+
value={cities}
|
|
215
|
+
onChange={setCities}
|
|
216
|
+
options={OPTIONS}
|
|
217
|
+
/>
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Sandbox
|
|
222
|
+
|
|
223
|
+
<Sandbox
|
|
224
|
+
Component={MultiSelect}
|
|
225
|
+
propsJsonSchema={MultiSelectPropsJsonSchema}
|
|
226
|
+
props={{
|
|
227
|
+
value: ['New York'],
|
|
228
|
+
options: ['New York', 'Los Angeles', 'Tokyo'],
|
|
229
|
+
onChange: value => console.info('New value is ' + JSON.stringify(value)),
|
|
230
|
+
onSelect: value => console.info('Value \"' + value + '\" is selected'),
|
|
231
|
+
onRemove: value => console.info('Value \"' + value + '\" is removed'),
|
|
232
|
+
}}
|
|
233
|
+
block
|
|
234
|
+
/>
|
package/index.cssx.styl
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
$inputBg = var(--color-bg-main-strong)
|
|
2
|
+
$inputBorderWidth = 1px
|
|
3
|
+
$inputBorderColor = var(--color-border-main)
|
|
4
|
+
$inputBgDisabled = var(--color-bg-main)
|
|
5
|
+
$inputPlaceholderColor = var(--color-text-placeholder)
|
|
6
|
+
$focusedColor = var(--color-border-primary)
|
|
7
|
+
$errorColor = var(--color-border-error)
|
|
8
|
+
$swipeZoneColor = var(--color-bg-secondary-subtle)
|
|
9
|
+
$suggestionsPopoverMaxHeight = 25u
|
|
10
|
+
|
|
11
|
+
.input
|
|
12
|
+
padding 5px 1u
|
|
13
|
+
background-color $inputBg
|
|
14
|
+
border-width $inputBorderWidth
|
|
15
|
+
border-style solid
|
|
16
|
+
border-color $inputBorderColor
|
|
17
|
+
min-height 4u
|
|
18
|
+
min-width 8u
|
|
19
|
+
radius()
|
|
20
|
+
|
|
21
|
+
&.disabled
|
|
22
|
+
background-color $inputBgDisabled
|
|
23
|
+
|
|
24
|
+
&.focused
|
|
25
|
+
border-color $focusedColor
|
|
26
|
+
|
|
27
|
+
&.error
|
|
28
|
+
border-color $errorColor
|
|
29
|
+
|
|
30
|
+
.placeholder
|
|
31
|
+
color $inputPlaceholderColor
|
|
32
|
+
|
|
33
|
+
.tag
|
|
34
|
+
margin-right 0.5u
|
|
35
|
+
|
|
36
|
+
&.last
|
|
37
|
+
margin-right 0
|
|
38
|
+
|
|
39
|
+
.row
|
|
40
|
+
padding-right 3u
|
|
41
|
+
width 100%
|
|
42
|
+
|
|
43
|
+
.suggestions-web
|
|
44
|
+
padding 0 1u
|
|
45
|
+
max-height $suggestionsPopoverMaxHeight
|
|
46
|
+
|
|
47
|
+
.suggestions-native
|
|
48
|
+
padding 1u 0
|
|
49
|
+
|
|
50
|
+
.nativeListContent
|
|
51
|
+
padding-top 5u
|
|
52
|
+
|
|
53
|
+
&:part(swipe)
|
|
54
|
+
background-color $swipeZoneColor
|
|
55
|
+
|
|
56
|
+
.suggestion
|
|
57
|
+
cursor pointer
|
|
58
|
+
padding 1u 2u
|
|
59
|
+
flex-direction row
|
|
60
|
+
align-items center
|
|
61
|
+
|
|
62
|
+
+desktop()
|
|
63
|
+
padding 1u
|
|
64
|
+
|
|
65
|
+
.sugText
|
|
66
|
+
color var(--color-text-main)
|
|
67
|
+
font(h5)
|
|
68
|
+
|
|
69
|
+
+desktop()
|
|
70
|
+
font(body2)
|
|
71
|
+
|
|
72
|
+
.backdropStyle
|
|
73
|
+
// Greater value than @startup/ui's Modal has
|
|
74
|
+
z-index 1501
|
|
75
|
+
|
|
76
|
+
.popover
|
|
77
|
+
padding 0 .5u
|
|
78
|
+
max-height $suggestionsPopoverMaxHeight
|
|
79
|
+
min-width 20u
|
|
80
|
+
|
|
81
|
+
.ellipsis
|
|
82
|
+
margin 0 1u
|
|
83
|
+
|
|
84
|
+
.suggestionItem
|
|
85
|
+
padding 1u 0
|
|
86
|
+
|
|
87
|
+
.check
|
|
88
|
+
width 2u
|
|
89
|
+
|
|
90
|
+
.checkIcon
|
|
91
|
+
color var(--color-text-primary)
|
|
92
|
+
|
|
93
|
+
.label
|
|
94
|
+
margin-right 1u
|
|
95
|
+
flex-grow 1
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// DO NOT MODIFY THIS FILE - IT IS AUTOMATICALLY GENERATED ON COMMITS.
|
|
3
|
+
|
|
4
|
+
import { type ReactNode, type RefObject } from 'react';
|
|
5
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
6
|
+
import './index.cssx.styl';
|
|
7
|
+
declare const _default: import("react").ComponentType<MultiSelectProps>;
|
|
8
|
+
export default _default;
|
|
9
|
+
export declare const _PropsJsonSchema: {};
|
|
10
|
+
export interface MultiSelectOption {
|
|
11
|
+
label: any;
|
|
12
|
+
value: any;
|
|
13
|
+
}
|
|
14
|
+
export interface MultiSelectProps {
|
|
15
|
+
/** Custom styles for the Popover anchor wrapper */
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
/** Custom styles for the input wrapper */
|
|
18
|
+
inputStyle?: StyleProp<ViewStyle>;
|
|
19
|
+
/** Available options (objects with `{ label, value }` or primitives) @default [] */
|
|
20
|
+
options?: Array<MultiSelectOption | string | number | boolean>;
|
|
21
|
+
/** Selected values @default [] */
|
|
22
|
+
value?: any[];
|
|
23
|
+
/** Placeholder text shown when empty @default 'Select' */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
/** Disable interactions @default false */
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
/** Render non-editable value @default false */
|
|
28
|
+
readonly?: boolean;
|
|
29
|
+
/** Maximum number of visible tags (extra tags are collapsed) */
|
|
30
|
+
tagLimit?: number;
|
|
31
|
+
/** Behavior when tags are limited (legacy prop) @default 'hidden' */
|
|
32
|
+
tagLimitVariant?: 'hidden' | 'disabled';
|
|
33
|
+
/** Maximum number of selectable tags */
|
|
34
|
+
maxTagCount?: number;
|
|
35
|
+
/** Custom tag renderer */
|
|
36
|
+
TagComponent?: any;
|
|
37
|
+
/** Custom input renderer */
|
|
38
|
+
InputComponent?: any;
|
|
39
|
+
/** Match Popover width to anchor on web @default false */
|
|
40
|
+
hasWidthCaption?: boolean;
|
|
41
|
+
/** Custom suggestion item renderer */
|
|
42
|
+
renderListItem?: (options: {
|
|
43
|
+
item: MultiSelectOption;
|
|
44
|
+
index: number;
|
|
45
|
+
selected: boolean;
|
|
46
|
+
}) => ReactNode;
|
|
47
|
+
/** Called when selected values change */
|
|
48
|
+
onChange?: (value: any[]) => void;
|
|
49
|
+
/** Called when a value is selected */
|
|
50
|
+
onSelect?: (value: any) => void;
|
|
51
|
+
/** Called when a value is removed */
|
|
52
|
+
onRemove?: (value: any) => void;
|
|
53
|
+
/** Called when dropdown opens */
|
|
54
|
+
onFocus?: () => void;
|
|
55
|
+
/** Called when dropdown closes */
|
|
56
|
+
onBlur?: () => void;
|
|
57
|
+
/** Ref providing imperative `focus()` / `blur()` methods */
|
|
58
|
+
ref?: RefObject<any>;
|
|
59
|
+
/** Error flag @private */
|
|
60
|
+
_hasError?: boolean;
|
|
61
|
+
}
|
package/index.tsx
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useImperativeHandle,
|
|
5
|
+
useMemo,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject
|
|
9
|
+
} from 'react'
|
|
10
|
+
import { Platform, type StyleProp, type ViewStyle } from 'react-native'
|
|
11
|
+
import { pug, observer, useDidUpdate } from 'startupjs'
|
|
12
|
+
import { themed } from '@startupjs-ui/core'
|
|
13
|
+
import Div from '@startupjs-ui/div'
|
|
14
|
+
import Drawer from '@startupjs-ui/drawer'
|
|
15
|
+
import Icon from '@startupjs-ui/icon'
|
|
16
|
+
import Popover from '@startupjs-ui/popover'
|
|
17
|
+
import ScrollView from '@startupjs-ui/scroll-view'
|
|
18
|
+
import Span from '@startupjs-ui/span'
|
|
19
|
+
import Tag from '@startupjs-ui/tag'
|
|
20
|
+
import { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck'
|
|
21
|
+
import './index.cssx.styl'
|
|
22
|
+
|
|
23
|
+
const IS_WEB = Platform.OS === 'web'
|
|
24
|
+
|
|
25
|
+
export default observer(themed('MultiSelect', MultiSelect))
|
|
26
|
+
|
|
27
|
+
export const _PropsJsonSchema = {/* MultiSelectProps */} // used in docs generation
|
|
28
|
+
|
|
29
|
+
export interface MultiSelectOption {
|
|
30
|
+
label: any
|
|
31
|
+
value: any
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MultiSelectProps {
|
|
35
|
+
/** Custom styles for the Popover anchor wrapper */
|
|
36
|
+
style?: StyleProp<ViewStyle>
|
|
37
|
+
/** Custom styles for the input wrapper */
|
|
38
|
+
inputStyle?: StyleProp<ViewStyle>
|
|
39
|
+
/** Available options (objects with `{ label, value }` or primitives) @default [] */
|
|
40
|
+
options?: Array<MultiSelectOption | string | number | boolean>
|
|
41
|
+
/** Selected values @default [] */
|
|
42
|
+
value?: any[]
|
|
43
|
+
/** Placeholder text shown when empty @default 'Select' */
|
|
44
|
+
placeholder?: string
|
|
45
|
+
/** Disable interactions @default false */
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
/** Render non-editable value @default false */
|
|
48
|
+
readonly?: boolean
|
|
49
|
+
/** Maximum number of visible tags (extra tags are collapsed) */
|
|
50
|
+
tagLimit?: number
|
|
51
|
+
/** Behavior when tags are limited (legacy prop) @default 'hidden' */
|
|
52
|
+
tagLimitVariant?: 'hidden' | 'disabled'
|
|
53
|
+
/** Maximum number of selectable tags */
|
|
54
|
+
maxTagCount?: number
|
|
55
|
+
/** Custom tag renderer */
|
|
56
|
+
TagComponent?: any
|
|
57
|
+
/** Custom input renderer */
|
|
58
|
+
InputComponent?: any
|
|
59
|
+
/** Match Popover width to anchor on web @default false */
|
|
60
|
+
hasWidthCaption?: boolean
|
|
61
|
+
/** Custom suggestion item renderer */
|
|
62
|
+
renderListItem?: (options: { item: MultiSelectOption, index: number, selected: boolean }) => ReactNode
|
|
63
|
+
/** Called when selected values change */
|
|
64
|
+
onChange?: (value: any[]) => void
|
|
65
|
+
/** Called when a value is selected */
|
|
66
|
+
onSelect?: (value: any) => void
|
|
67
|
+
/** Called when a value is removed */
|
|
68
|
+
onRemove?: (value: any) => void
|
|
69
|
+
/** Called when dropdown opens */
|
|
70
|
+
onFocus?: () => void
|
|
71
|
+
/** Called when dropdown closes */
|
|
72
|
+
onBlur?: () => void
|
|
73
|
+
/** Ref providing imperative `focus()` / `blur()` methods */
|
|
74
|
+
ref?: RefObject<any>
|
|
75
|
+
/** Error flag @private */
|
|
76
|
+
_hasError?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function MultiSelect ({
|
|
80
|
+
style,
|
|
81
|
+
inputStyle,
|
|
82
|
+
options = [],
|
|
83
|
+
value = [],
|
|
84
|
+
placeholder = 'Select',
|
|
85
|
+
disabled = false,
|
|
86
|
+
readonly = false,
|
|
87
|
+
tagLimitVariant = 'hidden',
|
|
88
|
+
TagComponent = DefaultTag,
|
|
89
|
+
InputComponent,
|
|
90
|
+
tagLimit,
|
|
91
|
+
maxTagCount,
|
|
92
|
+
hasWidthCaption = false,
|
|
93
|
+
renderListItem,
|
|
94
|
+
onChange,
|
|
95
|
+
onSelect,
|
|
96
|
+
onRemove,
|
|
97
|
+
onFocus,
|
|
98
|
+
onBlur,
|
|
99
|
+
ref,
|
|
100
|
+
_hasError,
|
|
101
|
+
...props
|
|
102
|
+
}: MultiSelectProps): ReactNode {
|
|
103
|
+
const [focused, setFocused] = useState(false)
|
|
104
|
+
const isOpenable = !(disabled || readonly)
|
|
105
|
+
|
|
106
|
+
const normalizedOptions = useMemo(() => {
|
|
107
|
+
return options.map(opt => typeof opt === 'object' && opt !== null
|
|
108
|
+
? opt
|
|
109
|
+
: { label: opt, value: opt }
|
|
110
|
+
)
|
|
111
|
+
}, [options])
|
|
112
|
+
|
|
113
|
+
const shouldDisableSelection = maxTagCount
|
|
114
|
+
? maxTagCount === value.length
|
|
115
|
+
: false
|
|
116
|
+
|
|
117
|
+
const focusHandler = useCallback(() => {
|
|
118
|
+
if (isOpenable) setFocused(true)
|
|
119
|
+
}, [isOpenable])
|
|
120
|
+
|
|
121
|
+
const blurHandler = useCallback(() => { setFocused(false) }, [])
|
|
122
|
+
|
|
123
|
+
const handleChangeVisible = (nextVisible: boolean) => {
|
|
124
|
+
if (!nextVisible) {
|
|
125
|
+
setFocused(false)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (!isOpenable) return
|
|
129
|
+
setFocused(true)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
useDidUpdate(() => {
|
|
133
|
+
if (focused) onFocus && onFocus()
|
|
134
|
+
else onBlur && onBlur()
|
|
135
|
+
}, [focused])
|
|
136
|
+
|
|
137
|
+
useImperativeHandle(ref, () => ({
|
|
138
|
+
focus: focusHandler,
|
|
139
|
+
blur: blurHandler
|
|
140
|
+
}), [focusHandler, blurHandler])
|
|
141
|
+
|
|
142
|
+
useDidUpdate(() => {
|
|
143
|
+
if (focused && !isOpenable) blurHandler()
|
|
144
|
+
}, [focused, isOpenable])
|
|
145
|
+
|
|
146
|
+
function _onRemove (_value: any) {
|
|
147
|
+
onRemove && onRemove(_value)
|
|
148
|
+
onChange && onChange(value.filter(v => v !== _value))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _onSelect (_value: any) {
|
|
152
|
+
onSelect && onSelect(_value)
|
|
153
|
+
onChange && onChange([...value, _value])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const onItemPress = (itemValue: any, selected: boolean) => (checked: boolean) => {
|
|
157
|
+
if (disabled || readonly) return
|
|
158
|
+
if (shouldDisableSelection && checked && !selected) return
|
|
159
|
+
if (!checked) {
|
|
160
|
+
_onRemove(itemValue)
|
|
161
|
+
} else {
|
|
162
|
+
_onSelect(itemValue)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _renderListItem ({ item, index }: { item: MultiSelectOption, index: number }): ReactNode {
|
|
167
|
+
const { label, value: itemValue } = item
|
|
168
|
+
const selected = value.includes(itemValue)
|
|
169
|
+
const onPress = onItemPress(itemValue, selected)
|
|
170
|
+
|
|
171
|
+
return pug`
|
|
172
|
+
Div(
|
|
173
|
+
key=itemValue
|
|
174
|
+
vAlign='center'
|
|
175
|
+
disabled=selected ? false : shouldDisableSelection
|
|
176
|
+
onPress=() => onPress(!selected)
|
|
177
|
+
)
|
|
178
|
+
if renderListItem
|
|
179
|
+
= renderListItem({ item, index, selected })
|
|
180
|
+
else
|
|
181
|
+
Div.suggestionItem(row)
|
|
182
|
+
Span.label= label
|
|
183
|
+
Div.check
|
|
184
|
+
if selected
|
|
185
|
+
Icon(icon=faCheck styleName='checkIcon')
|
|
186
|
+
`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderContent (): ReactNode {
|
|
190
|
+
return pug`
|
|
191
|
+
ScrollView.suggestions-web
|
|
192
|
+
each option, index in normalizedOptions
|
|
193
|
+
= _renderListItem({ item: option, index })
|
|
194
|
+
`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return IS_WEB
|
|
198
|
+
? pug`
|
|
199
|
+
Popover.popover(
|
|
200
|
+
part='root'
|
|
201
|
+
...props
|
|
202
|
+
captionStyle=style
|
|
203
|
+
visible=focused
|
|
204
|
+
matchAnchorWidth=hasWidthCaption
|
|
205
|
+
attachment='start'
|
|
206
|
+
position='bottom'
|
|
207
|
+
onChange=handleChangeVisible
|
|
208
|
+
renderContent=renderContent
|
|
209
|
+
)
|
|
210
|
+
MultiSelectInput(
|
|
211
|
+
part='input'
|
|
212
|
+
style=inputStyle
|
|
213
|
+
focused=focused
|
|
214
|
+
value=value
|
|
215
|
+
placeholder=placeholder
|
|
216
|
+
tagLimit=tagLimit
|
|
217
|
+
tagLimitVariant=tagLimitVariant
|
|
218
|
+
options=normalizedOptions
|
|
219
|
+
disabled=disabled
|
|
220
|
+
readonly=readonly
|
|
221
|
+
InputComponent=InputComponent
|
|
222
|
+
TagComponent=TagComponent
|
|
223
|
+
_hasError=_hasError
|
|
224
|
+
onOpen=focusHandler
|
|
225
|
+
onHide=blurHandler
|
|
226
|
+
)
|
|
227
|
+
`
|
|
228
|
+
: pug`
|
|
229
|
+
MultiSelectInput(
|
|
230
|
+
part='input'
|
|
231
|
+
style=inputStyle
|
|
232
|
+
onOpen=focusHandler
|
|
233
|
+
onHide=blurHandler
|
|
234
|
+
focused=focused
|
|
235
|
+
value=value
|
|
236
|
+
placeholder=placeholder
|
|
237
|
+
tagLimit=tagLimit
|
|
238
|
+
tagLimitVariant=tagLimitVariant
|
|
239
|
+
options=normalizedOptions
|
|
240
|
+
disabled=disabled
|
|
241
|
+
readonly=readonly
|
|
242
|
+
InputComponent=InputComponent
|
|
243
|
+
TagComponent=TagComponent
|
|
244
|
+
_hasError=_hasError
|
|
245
|
+
)
|
|
246
|
+
Drawer.nativeListContent(
|
|
247
|
+
part='root'
|
|
248
|
+
visible=focused
|
|
249
|
+
position='bottom'
|
|
250
|
+
onDismiss=blurHandler
|
|
251
|
+
)
|
|
252
|
+
ScrollView.suggestions-native
|
|
253
|
+
each option, index in normalizedOptions
|
|
254
|
+
= _renderListItem({ item: option, index })
|
|
255
|
+
`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function MultiSelectInput ({
|
|
259
|
+
style,
|
|
260
|
+
value,
|
|
261
|
+
placeholder,
|
|
262
|
+
options,
|
|
263
|
+
disabled,
|
|
264
|
+
readonly,
|
|
265
|
+
focused,
|
|
266
|
+
tagLimit,
|
|
267
|
+
tagLimitVariant,
|
|
268
|
+
TagComponent,
|
|
269
|
+
InputComponent,
|
|
270
|
+
onOpen,
|
|
271
|
+
onHide,
|
|
272
|
+
_hasError
|
|
273
|
+
}: {
|
|
274
|
+
style?: StyleProp<ViewStyle>
|
|
275
|
+
value: any[]
|
|
276
|
+
placeholder?: string
|
|
277
|
+
options: MultiSelectOption[]
|
|
278
|
+
disabled?: boolean
|
|
279
|
+
readonly?: boolean
|
|
280
|
+
focused?: boolean
|
|
281
|
+
tagLimit?: number
|
|
282
|
+
tagLimitVariant?: 'hidden' | 'disabled'
|
|
283
|
+
TagComponent?: any
|
|
284
|
+
InputComponent?: any
|
|
285
|
+
onOpen?: () => void
|
|
286
|
+
onHide?: () => void
|
|
287
|
+
_hasError?: boolean
|
|
288
|
+
}): ReactNode {
|
|
289
|
+
const values = tagLimit ? value.slice(0, tagLimit) : value
|
|
290
|
+
const hiddenTagsLength = tagLimit
|
|
291
|
+
? value.slice(tagLimit, value.length).length
|
|
292
|
+
: 0
|
|
293
|
+
|
|
294
|
+
const EffectiveInputComponent = InputComponent ?? DefaultInput
|
|
295
|
+
|
|
296
|
+
return pug`
|
|
297
|
+
EffectiveInputComponent(
|
|
298
|
+
part='root'
|
|
299
|
+
style=style
|
|
300
|
+
value=values
|
|
301
|
+
placeholder=placeholder
|
|
302
|
+
disabled=disabled
|
|
303
|
+
focused=focused
|
|
304
|
+
readonly=readonly
|
|
305
|
+
onOpen=onOpen
|
|
306
|
+
onHide=onHide
|
|
307
|
+
_hasError=_hasError
|
|
308
|
+
)
|
|
309
|
+
each value, index in values
|
|
310
|
+
- const record = options.find(r => r.value === value) || {}
|
|
311
|
+
- const isLast = index + 1 === values.length
|
|
312
|
+
TagComponent(
|
|
313
|
+
key=value
|
|
314
|
+
index=index
|
|
315
|
+
isLast=isLast
|
|
316
|
+
record=record
|
|
317
|
+
)
|
|
318
|
+
if hiddenTagsLength
|
|
319
|
+
Span.ellipsis ...
|
|
320
|
+
DefaultTag(
|
|
321
|
+
index=0
|
|
322
|
+
record={ label: '+' + hiddenTagsLength }
|
|
323
|
+
)
|
|
324
|
+
`
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function DefaultInput ({
|
|
328
|
+
style,
|
|
329
|
+
value = [],
|
|
330
|
+
placeholder,
|
|
331
|
+
disabled,
|
|
332
|
+
focused,
|
|
333
|
+
readonly,
|
|
334
|
+
children,
|
|
335
|
+
onOpen,
|
|
336
|
+
onHide,
|
|
337
|
+
_hasError,
|
|
338
|
+
ref
|
|
339
|
+
}: {
|
|
340
|
+
style?: StyleProp<ViewStyle>
|
|
341
|
+
value?: any[]
|
|
342
|
+
placeholder?: string
|
|
343
|
+
disabled?: boolean
|
|
344
|
+
focused?: boolean
|
|
345
|
+
readonly?: boolean
|
|
346
|
+
children?: ReactNode
|
|
347
|
+
onOpen?: () => void
|
|
348
|
+
onHide?: () => void
|
|
349
|
+
_hasError?: boolean
|
|
350
|
+
ref?: RefObject<any>
|
|
351
|
+
}): ReactNode {
|
|
352
|
+
useImperativeHandle(ref, () => ({
|
|
353
|
+
focus: () => { onOpen && onOpen() },
|
|
354
|
+
blur: () => { onHide && onHide() }
|
|
355
|
+
}), [onOpen, onHide])
|
|
356
|
+
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
if (focused && disabled) onHide && onHide()
|
|
359
|
+
}, [disabled, focused, onHide])
|
|
360
|
+
|
|
361
|
+
return pug`
|
|
362
|
+
if readonly
|
|
363
|
+
Span= value.join(', ')
|
|
364
|
+
else
|
|
365
|
+
Div.input(
|
|
366
|
+
style=style
|
|
367
|
+
styleName={ disabled, focused, readonly, error: _hasError }
|
|
368
|
+
onPress=disabled || readonly ? void 0 : onOpen
|
|
369
|
+
wrap
|
|
370
|
+
row
|
|
371
|
+
)
|
|
372
|
+
if !value.length
|
|
373
|
+
Span.placeholder= placeholder || '-'
|
|
374
|
+
|
|
375
|
+
= children
|
|
376
|
+
`
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function DefaultTag ({
|
|
380
|
+
record,
|
|
381
|
+
isLast
|
|
382
|
+
}: {
|
|
383
|
+
record?: any
|
|
384
|
+
isLast?: boolean
|
|
385
|
+
}): ReactNode {
|
|
386
|
+
return pug`
|
|
387
|
+
Tag.tag(
|
|
388
|
+
styleName={ last: isLast }
|
|
389
|
+
size='s'
|
|
390
|
+
variant='flat'
|
|
391
|
+
color='primary'
|
|
392
|
+
)= record?.label
|
|
393
|
+
`
|
|
394
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startupjs-ui/multi-select",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"main": "index.tsx",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@startupjs-ui/core": "^0.1.3",
|
|
12
|
+
"@startupjs-ui/div": "^0.1.3",
|
|
13
|
+
"@startupjs-ui/drawer": "^0.1.3",
|
|
14
|
+
"@startupjs-ui/icon": "^0.1.3",
|
|
15
|
+
"@startupjs-ui/popover": "^0.1.3",
|
|
16
|
+
"@startupjs-ui/scroll-view": "^0.1.3",
|
|
17
|
+
"@startupjs-ui/span": "^0.1.3",
|
|
18
|
+
"@startupjs-ui/tag": "^0.1.3"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": "*",
|
|
22
|
+
"react-native": "*",
|
|
23
|
+
"startupjs": "*"
|
|
24
|
+
},
|
|
25
|
+
"gitHead": "fd964ebc3892d3dd0a6c85438c0af619cc50c3f0"
|
|
26
|
+
}
|