haya-select 1.0.0

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/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "haya-select",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "author": "",
10
+ "license": "ISC",
11
+ "dependencies": {
12
+ "classnames": "^2.3.1",
13
+ "debounce": "^1.2.1",
14
+ "diggerize": "^1.0.4"
15
+ }
16
+ }
package/src/index.jsx ADDED
@@ -0,0 +1,322 @@
1
+ import "./style"
2
+ import BodyPortal from "components/layout/body-portal"
3
+ import classNames from "classnames"
4
+ import {digg, digs} from "diggerize"
5
+ import debounce from "debounce"
6
+ import {idForComponent, nameForComponent} from "@kaspernj/api-maker-inputs"
7
+ import PropTypes from "prop-types"
8
+
9
+ const nameForComponentWithMultiple = (component) => {
10
+ let name = nameForComponent(component)
11
+
12
+ if (component.props.multiple) name += "[]"
13
+
14
+ return name
15
+ }
16
+
17
+ const presentOption = (currentValue) => {
18
+ if (currentValue.text) return currentValue.text
19
+
20
+ if (currentValue.html) {
21
+ return (
22
+ <div dangerouslySetInnerHTML={{__html: digg(currentValue, "html")}} />
23
+ )
24
+ }
25
+
26
+ throw new Error("Couldn't figure out how to present option")
27
+ }
28
+
29
+ const LoadedOption = class LoadedOption extends BaseComponent {
30
+ render() {
31
+ const {currentOptions, loadedOption} = digs(this.props, "currentOptions", "loadedOption")
32
+ const selected = Boolean(currentOptions.find((currentOption) => currentOption.value == loadedOption.value))
33
+
34
+ return (
35
+ <div
36
+ className="custom-select-option"
37
+ data-selected={selected}
38
+ data-value={digg(loadedOption, "value")}
39
+ onClick={this.onOptionClicked}
40
+ style={{cursor: "pointer"}}
41
+ >
42
+ {presentOption(loadedOption)}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ onOptionClicked = (e) => this.props.onClick(e, this.props.loadedOption)
48
+ }
49
+
50
+ export default class CustomSelect extends React.PureComponent {
51
+ static defaultProps = {
52
+ multiple: false,
53
+ search: false
54
+ }
55
+
56
+ static propTypes = {
57
+ attribute: PropTypes.string,
58
+ className: PropTypes.string,
59
+ defaultValues: PropTypes.array,
60
+ defaultValuesFromOptions: PropTypes.array,
61
+ model: PropTypes.object,
62
+ multiple: PropTypes.bool.isRequired,
63
+ onChange: PropTypes.func,
64
+ options: PropTypes.oneOfType([PropTypes.array, PropTypes.func]).isRequired,
65
+ search: PropTypes.bool.isRequired
66
+ }
67
+
68
+ endOfSelectRef = React.createRef()
69
+ optionsContainerRef = React.createRef()
70
+ searchTextInputRef = React.createRef()
71
+
72
+ state = {
73
+ currentOptions: [],
74
+ loadedOptions: this.defaultLoadedOptions(),
75
+ opened: false,
76
+ optionsLeft: undefined,
77
+ optionsTop: undefined,
78
+ optionsWidth: undefined,
79
+ scrollLeft: undefined,
80
+ scrollTop: undefined
81
+ }
82
+
83
+ defaultLoadedOptions() {
84
+ const {options} = digs(this.props, "options")
85
+
86
+ if (typeof options == "function") return undefined
87
+
88
+ return options
89
+ }
90
+
91
+ componentDidMount() {
92
+ const {attribute, defaultValuesFromOptions, model, options} = this.props
93
+
94
+ if ((defaultValuesFromOptions || (attribute && model)) && typeof options == "function") {
95
+ this.loadDefaultValuesFromOptionsCallback()
96
+ }
97
+ }
98
+
99
+ render() {
100
+ const {endOfSelectRef} = digs(this, "endOfSelectRef")
101
+ const {attribute, className, defaultValues, model, multiple, onChange, options, search, ...restProps} = this.props
102
+ const {currentOptions, opened} = digs(this.state, "currentOptions", "opened")
103
+
104
+ return (
105
+ <div
106
+ className={classNames("components-custom-select", className)}
107
+ data-id={idForComponent(this)}
108
+ {...restProps}
109
+ >
110
+ {opened &&
111
+ <BodyPortal>
112
+ {this.optionsContainer()}
113
+ </BodyPortal>
114
+ }
115
+ <div
116
+ className="custom-select-current-selected"
117
+ data-opened={opened}
118
+ onClick={this.onSelectClicked}
119
+ >
120
+ {opened &&
121
+ <input
122
+ className="custom-select-search-text-input"
123
+ onChange={digg(this, "onSearchTextInputChangedDebounced")}
124
+ placeholder={I18n.t("js.components.custom_select.search_dot_dot_dot")} type="text"
125
+ ref={digg(this, "searchTextInputRef")}
126
+ />
127
+ }
128
+ {!opened &&
129
+ <>
130
+ {currentOptions.length == 0 &&
131
+ <div style={{color: "grey"}}>
132
+ {I18n.t("js.components.custom_select.nothing_selected")}
133
+ </div>
134
+ }
135
+ {currentOptions.map((currentOption) =>
136
+ <div className="current-option" key={`current-value-${digg(currentOption, "value")}`}>
137
+ <input
138
+ id={idForComponent(this)}
139
+ name={nameForComponentWithMultiple(this)}
140
+ type="hidden"
141
+ value={digg(currentOption, "value")}
142
+ />
143
+ {presentOption(currentOption)}
144
+ </div>
145
+ )}
146
+ </>
147
+ }
148
+ </div>
149
+ <div ref={endOfSelectRef} />
150
+ </div>
151
+ )
152
+ }
153
+
154
+ defaultValues () {
155
+ const {attribute, defaultValuesFromOptions, model} = this.props
156
+
157
+ if (defaultValuesFromOptions) return defaultValuesFromOptions
158
+
159
+ if (attribute && model) {
160
+ if (!(attribute in model)) throw new Error(`No such attribute on ${model.modelClassData().name}: ${attribute}`)
161
+
162
+ return model[attribute]()
163
+ }
164
+ }
165
+
166
+ async loadDefaultValuesFromOptionsCallback() {
167
+ const defaultValues = this.defaultValues()
168
+
169
+ if (!defaultValues) return
170
+
171
+ const result = await this.props.options({
172
+ searchValue: digg(this, "searchTextInputRef", "current")?.value,
173
+ values: defaultValues
174
+ })
175
+
176
+ this.setState((prevState) => ({
177
+ currentOptions: prevState.currentOptions.concat(result)
178
+ }))
179
+ }
180
+
181
+ loadOptions = async () => {
182
+ const {options} = digs(this.props, "options")
183
+ const loadedOptions = await options({
184
+ searchValue: digg(this, "searchTextInputRef", "current")?.value
185
+ })
186
+
187
+ this.setState({loadedOptions})
188
+ }
189
+
190
+ onSelectClicked = (e) => {
191
+ e.preventDefault()
192
+ e.stopPropagation()
193
+
194
+ const {opened} = digs(this.state, "opened")
195
+
196
+ if (opened) {
197
+ this.closeOptions()
198
+ } else {
199
+ this.openOptions()
200
+ }
201
+ }
202
+
203
+ onSearchTextInputChangedDebounced = debounce(digg(this, "loadOptions"), 200)
204
+
205
+ closeOptions() {
206
+ this.setState({
207
+ loadedOptions: undefined,
208
+ opened: false
209
+ })
210
+ }
211
+
212
+ openOptions() {
213
+ const {endOfSelectRef} = digs(this, "endOfSelectRef")
214
+ const position = endOfSelectRef.current.getBoundingClientRect()
215
+ const {left, top, width} = digs(position, "left", "top", "width")
216
+
217
+ this.loadOptions()
218
+
219
+ this.setState(
220
+ {
221
+ opened: true,
222
+ optionsLeft: left,
223
+ optionsTop: top,
224
+ optionsWidth: width,
225
+ scrollLeft: document.documentElement.scrollLeft,
226
+ scrollTop: document.documentElement.scrollTop
227
+ },
228
+ () => {
229
+ digg(this, "searchTextInputRef", "current").focus()
230
+ }
231
+ )
232
+ }
233
+
234
+ onWindowClicked = (e) => {
235
+ const {optionsContainerRef} = digs(this, "optionsContainerRef")
236
+ const {opened} = digs(this.state, "opened")
237
+
238
+ // If options are open and a click is made outside of the options container
239
+ if (opened && optionsContainerRef.current && !optionsContainerRef.current?.contains(e.target)) {
240
+ this.setState({opened: false})
241
+ }
242
+ }
243
+
244
+ optionsContainer() {
245
+ const {optionsContainerRef} = digs(this, "endOfSelectRef", "optionsContainerRef")
246
+ const {
247
+ currentOptions,
248
+ loadedOptions,
249
+ optionsLeft,
250
+ optionsTop,
251
+ optionsWidth,
252
+ scrollLeft,
253
+ scrollTop
254
+ } = digs(
255
+ this.state,
256
+ "currentOptions",
257
+ "loadedOptions",
258
+ "currentOptions",
259
+ "opened",
260
+ "optionsLeft",
261
+ "optionsTop",
262
+ "optionsWidth",
263
+ "scrollLeft",
264
+ "scrollTop"
265
+ )
266
+
267
+ return (
268
+ <div
269
+ className="custom-select-options-container"
270
+ data-id={idForComponent(this)}
271
+ ref={optionsContainerRef}
272
+ style={{
273
+ left: optionsLeft + scrollLeft,
274
+ top: optionsTop + scrollTop - 1,
275
+ width: optionsWidth,
276
+ }}
277
+ >
278
+ <EventListener event="click" onCalled={this.onWindowClicked} target={window} />
279
+ {loadedOptions?.map((loadedOption) =>
280
+ <LoadedOption
281
+ currentOptions={currentOptions}
282
+ key={`loaded-option-${digg(loadedOption.value)}`}
283
+ loadedOption={loadedOption}
284
+ onClick={this.onOptionClicked}
285
+ />
286
+ )}
287
+ {loadedOptions?.length === 0 &&
288
+ <div className="custom-select-no-options-container">
289
+ {I18n.t("js.components.custom_select.no_options_found")}
290
+ </div>
291
+ }
292
+ </div>
293
+ )
294
+ }
295
+
296
+ onOptionClicked = (e, loadedOption) => {
297
+ e.preventDefault()
298
+ e.stopPropagation()
299
+
300
+ const {onChange} = this.props
301
+ const {multiple} = digs(this.props, "multiple")
302
+
303
+ this.setState((prevState) => {
304
+ const existingOption = prevState.currentOptions.find((currentOption) => currentOption.value == loadedOption.value)
305
+
306
+ if (existingOption) {
307
+ return {
308
+ currentOptions: prevState.currentOptions.filter((currentOption) => currentOption.value != loadedOption.value)
309
+ }
310
+ } else {
311
+ if (multiple) {
312
+ return {currentOptions: prevState.currentOptions.concat([loadedOption])}
313
+ } else {
314
+ return {currentOptions: [loadedOption]}
315
+ }
316
+ }
317
+ })
318
+
319
+ if (!multiple) this.closeOptions()
320
+ if (onChange) onChange(loadedOption)
321
+ }
322
+ }
package/src/style.scss ADDED
@@ -0,0 +1,41 @@
1
+ .custom-select-current-selected {
2
+ background-color: #fff;
3
+ border: 1px solid #999;
4
+ cursor: pointer;
5
+ padding: 5px;
6
+
7
+ &[data-opened="false"] {
8
+ border-radius: 4px;
9
+ }
10
+
11
+ &[data-opened="true"] {
12
+ border-radius: 4px 4px 0 0;
13
+ }
14
+ }
15
+
16
+ .custom-select-search-text-input {
17
+ border: 0;
18
+ outline: none;
19
+ padding: 0;
20
+ }
21
+
22
+ .custom-select-options-container {
23
+ background-color: #fff;
24
+ border: 1px solid #999;
25
+ max-height: 300px;
26
+ overflow-y: auto;
27
+ position: absolute;
28
+ z-index: 99999;
29
+
30
+ .custom-select-option {
31
+ padding: 4px 8px;
32
+
33
+ &[data-selected="true"] {
34
+ background-color: #80b2ff;
35
+ }
36
+
37
+ &:hover {
38
+ background-color: steelblue;
39
+ }
40
+ }
41
+ }