@dvrd/dvr-controls 1.0.81 → 1.0.83

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/index.ts CHANGED
@@ -47,7 +47,7 @@ import Media from './src/js/media/media';
47
47
  import DvrdButton, {DvrdButtonProps} from './src/js/button/dvrdButton';
48
48
  import DvrdDatePicker, {DVRDDatePickerRef} from './src/js/date/dvrdDatePicker';
49
49
  import DvrdInputController from './src/js/textField/dvrdInputController';
50
- import DvrdNumberInput from './src/js/textField/dvrdNumberInput';
50
+ import DVRDNumberInput from './src/js/textField/dvrdNumberInput';
51
51
  import DVRDPasswordInput from './src/js/textField/dvrdPasswordInput';
52
52
  import Link from './src/js/link/link';
53
53
  import DvrdOptionsList from './src/js/optionsList/dvrdOptionsList';
@@ -98,7 +98,7 @@ export {
98
98
  DvrdDatePicker,
99
99
  DVRDDatePickerRef,
100
100
  DvrdInputController as DvrdInput,
101
- DvrdNumberInput,
101
+ DVRDNumberInput,
102
102
  DVRDPasswordInput,
103
103
  Link,
104
104
  DvrdOptionsList,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dvrd/dvr-controls",
3
- "version": "1.0.81",
3
+ "version": "1.0.83",
4
4
  "description": "Custom web controls",
5
5
  "main": "index.ts",
6
6
  "files": [
@@ -8,8 +8,8 @@ import PdfInvoiceTable from "../../invoiceTable/pdfInvoiceTable";
8
8
  import {debug} from "../../../util/miscUtil";
9
9
  import {ElementPosition} from "../../../util/interfaces";
10
10
  import {fontItems} from "../../../util/pdfUtil";
11
- import {ColorPicker, DvrdNumberInput, DvrdSelect} from "../../../../../index";
12
11
  import IconButton, {IconButtonType} from "../buttons/iconButton";
12
+ import {ColorPicker, DVRDNumberInput, DvrdSelect} from "../../../../../index";
13
13
 
14
14
  interface Props {
15
15
  element: PdfInvoiceTable;
@@ -116,7 +116,7 @@ export default class PdfInvoiceTableSettings extends PureComponent<Props, State>
116
116
  renderWidth = (value: number, index: number) => {
117
117
  const title = TITLES[index];
118
118
  return (
119
- <DvrdNumberInput key={index} value={value.toString()} onChange={this.onChangeWidth(index)} label={title}
119
+ <DVRDNumberInput key={index} value={value.toString()} onChange={this.onChangeWidth(index)} label={title}
120
120
  ornaments={{
121
121
  element: <span style={{cursor: 'text'}}>%</span>,
122
122
  placement: ElementPosition.RIGHT,
@@ -145,7 +145,7 @@ export default class PdfInvoiceTableSettings extends PureComponent<Props, State>
145
145
  <label className='settings-label'>Tekstgrootte</label>
146
146
  <DvrdSelect items={fontItems} value={settings.font} onChange={element.changeSetting('font')}
147
147
  label='Lettertype' optionsContainerHeight='15rem' unControlled/>
148
- <DvrdNumberInput value={settings.fontSize} onChange={element.changeSetting('fontSize')}
148
+ <DVRDNumberInput value={settings.fontSize} onChange={element.changeSetting('fontSize')}
149
149
  asNumber
150
150
  ornaments={{
151
151
  element: <span style={{cursor: 'text'}}>px</span>,
@@ -14,7 +14,7 @@ import {
14
14
  PDFTextVariables,
15
15
  SelectItemShape
16
16
  } from "../../../util/interfaces";
17
- import {ColorPicker, DvrdNumberInput, DvrdSelect, fontItems, getPdfVariables} from "../../../../../index";
17
+ import {ColorPicker, DVRDNumberInput, DvrdSelect, fontItems, getPdfVariables} from "../../../../../index";
18
18
  import classNames from 'classnames';
19
19
  import IconButton, {IconButtonType} from "../buttons/iconButton";
20
20
 
@@ -177,7 +177,7 @@ export default class PdfTextSettings extends PureComponent<Props, State> {
177
177
  <div className='font-settings'>
178
178
  <DvrdSelect items={fontItems} value={settings.font} onChange={element.changeSetting('font')}
179
179
  label='Lettertype' optionsContainerHeight='15rem' unControlled/>
180
- <DvrdNumberInput value={settings.fontSize} onChange={element.changeSetting('fontSize')}
180
+ <DVRDNumberInput value={settings.fontSize} onChange={element.changeSetting('fontSize')}
181
181
  label='Tekstgrootte' className='font-size'
182
182
  ornaments={{
183
183
  element: <span style={{cursor: 'text'}}>px</span>,
@@ -8,6 +8,7 @@ import {ErrorType, GroupedSelectItem, SelectValueType} from "../util/interfaces"
8
8
  import classNames from 'classnames';
9
9
  import AwesomeIcon from "../icon/awesomeIcon";
10
10
  import defer from 'lodash.defer';
11
+ import {stopPropagation} from "../util/controlUtil";
11
12
 
12
13
 
13
14
  interface Props {
@@ -27,6 +28,7 @@ interface Props {
27
28
  maxItemsHeight?: number | string;
28
29
  highlightSelected?: true;
29
30
  placeholder?: string;
31
+ searchable?: boolean;
30
32
  }
31
33
 
32
34
  export type GroupedSelectRef = { open: VoidFunction; close: VoidFunction; toggle: (forcedValue?: boolean) => void };
@@ -42,12 +44,19 @@ function findInItems(items: Array<GroupedSelectItem>, value: string | number): G
42
44
  return null;
43
45
  }
44
46
 
47
+ function itemMatchesSearch(item: GroupedSelectItem, search: string): boolean {
48
+ if (!search) return true;
49
+ return item.label.toString().toLowerCase().includes(search.toLowerCase());
50
+ }
51
+
45
52
  function DVRDGroupedSelect(props: Props, ref: ForwardedRef<GroupedSelectRef>) {
46
53
  const {
47
54
  className, label, labelClassName, value, placeholder, valueClassName, error, itemContainerClassName,
48
- onChange, items, itemClassName, disabled, errorClassName, highlightSelected
55
+ onChange, items, itemClassName, disabled, errorClassName, highlightSelected, searchable
49
56
  } = props;
50
57
  const [open, setOpen] = useState(false);
58
+ const [search, setSearch] = useState('');
59
+ const [searchActive, setSearchActive] = useState(false);
51
60
  const maxItemsHeight: string | undefined = useMemo(() => {
52
61
  const {maxItemsHeight} = props;
53
62
  if (maxItemsHeight) {
@@ -71,8 +80,36 @@ function DVRDGroupedSelect(props: Props, ref: ForwardedRef<GroupedSelectRef>) {
71
80
  longest = item.label.toString();
72
81
  return longest;
73
82
  }, [items]);
83
+ const availableItems = useMemo(() => {
84
+ if (!searchable || !search.trim()) return items;
85
+ const _items: Array<GroupedSelectItem> = [];
86
+ for (const item of items) {
87
+ if (itemMatchesSearch(item, search)) _items.push(item);
88
+ else if (item.children) {
89
+ if (!!item.children.find((child: GroupedSelectItem) => itemMatchesSearch(child, search)))
90
+ _items.push(item);
91
+ }
92
+ }
93
+ return _items;
94
+ }, [items, searchable, search]);
74
95
  const itemsContainer = useRef<HTMLDivElement>(null);
75
96
 
97
+ function onChangeSearch(evt: React.ChangeEvent<HTMLInputElement>) {
98
+ const {value} = evt.target;
99
+ setSearch(value);
100
+ }
101
+
102
+ function onFocusSearch() {
103
+ if (searchable) {
104
+ setSearchActive(true);
105
+ setOpen(true);
106
+ }
107
+ }
108
+
109
+ function onBlurSearch() {
110
+ if (searchable) setSearchActive(false);
111
+ }
112
+
76
113
  function onSelectItem(item: GroupedSelectItem) {
77
114
  return function (evt: React.MouseEvent) {
78
115
  evt.stopPropagation();
@@ -122,36 +159,61 @@ function DVRDGroupedSelect(props: Props, ref: ForwardedRef<GroupedSelectRef>) {
122
159
  }
123
160
 
124
161
  function renderValue() {
125
- const hasValue = selectedLabel !== null;
126
- const canRender = hasValue && !['string', 'number'].includes(typeof selectedLabel)
127
162
  const chevIcon = itemsPosition === 'bottom' ? 'chevron-down' : 'chevron-up';
128
- const placeholderHidden = !placeholder?.length;
129
163
  return (
130
- <div className={classNames('grouped-select-value-container', valueClassName)}>
131
- {hasValue ?
132
- canRender ? renderCustomValue(selectedLabel as React.ReactElement) :
133
- <label className='grouped-select-value'>{selectedLabel}</label> :
134
- <label className={classNames('grouped-select-placeholder', placeholderHidden && 'hidden')}>
135
- {placeholderHidden ? 'placeholder' : placeholder}</label>
136
- }
164
+ <div className={classNames('grouped-select-value-container', searchable && 'searchable', valueClassName)}>
165
+ {renderValueInput()}
166
+ {renderValueCustom()}
167
+ {renderValueLabel()}
168
+ {renderValuePlaceholder()}
137
169
  <AwesomeIcon name={chevIcon} className='chev-icon'/>
138
170
  <div style={{height: 0, visibility: 'hidden'}}>{longestValue}</div>
139
171
  </div>
140
172
  );
141
173
  }
142
174
 
143
- function renderCustomValue(value: React.ReactElement) {
144
- return React.cloneElement(value, {
145
- ...value.props,
146
- className: classNames(value.props?.className, 'grouped-select-value')
175
+ function renderValueInput() {
176
+ if (!searchable) return null;
177
+ const _value = searchActive ? search : selectedLabel?.toString();
178
+ return (
179
+ <input onChange={onChangeSearch} value={_value} onFocus={onFocusSearch}
180
+ onBlur={onBlurSearch} className='dvrd-grouped-select-search' disabled={disabled}
181
+ onClick={stopPropagation} placeholder={placeholder}/>
182
+ )
183
+ }
184
+
185
+ function renderValueCustom() {
186
+ if (selectedLabel === null || searchable) return null; // Nothing selected, or using a search input
187
+ if (['string', 'number'].includes(typeof selectedLabel)) return null; // Not a custom element
188
+ const element = selectedLabel as React.ReactElement;
189
+ return React.cloneElement(element, {
190
+ ...element.props,
191
+ className: classNames(element.props?.className, 'grouped-select-value')
147
192
  });
148
193
  }
149
194
 
195
+ function renderValueLabel() {
196
+ if (selectedLabel === null || searchable) return null; // Nothing selected, or using a search input
197
+ if (!['string', 'number'].includes(typeof selectedLabel)) return null; // A custom element
198
+ return (
199
+ <label className='grouped-select-value'>{selectedLabel}</label>
200
+ );
201
+ }
202
+
203
+ function renderValuePlaceholder() {
204
+ if (selectedLabel !== null || searchable) return null; // Something is selected, or we are using a search input
205
+ const placeholderHidden = !placeholder?.length;
206
+ return (
207
+ <label className={classNames('grouped-select-placeholder', placeholderHidden && 'hidden')}>
208
+ {placeholderHidden ? 'placeholder' : placeholder}</label>
209
+ );
210
+ }
211
+
150
212
  function renderItemsContainer() {
151
213
  return (
152
214
  <div className={classNames('grouped-select-items', itemContainerClassName)}
153
215
  style={{maxHeight: maxItemsHeight}} ref={itemsContainer}>
154
- {items.map(renderItem())}
216
+ {availableItems.map(renderItem())}
155
217
  </div>
156
218
  )
157
219
  }
@@ -33,6 +33,16 @@
33
33
  cursor: pointer;
34
34
  background-color: white;
35
35
 
36
+ .dvrd-grouped-select-search {
37
+ width: 100%;
38
+ outline: none;
39
+ border: none;
40
+ padding: .75rem;
41
+ color: #2A435F;
42
+ border-radius: inherit;
43
+ font-family: avenir-light, sans-serif;
44
+ }
45
+
36
46
  .grouped-select-placeholder, .grouped-select-value {
37
47
  user-select: none;
38
48
  vertical-align: middle;
@@ -55,6 +65,10 @@
55
65
  transition: transform .2s ease-in-out;
56
66
  color: $color-gray-3;
57
67
  }
68
+
69
+ &.searchable {
70
+ padding: 0 .75rem 0 0;
71
+ }
58
72
  }
59
73
 
60
74
  .grouped-select-items {
@@ -2,10 +2,12 @@
2
2
  * Copyright (c) 2024. Dave van Rijn Development
3
3
  */
4
4
 
5
- import React, {FocusEventHandler, InputHTMLAttributes, KeyboardEventHandler, PureComponent} from 'react';
5
+ import React, {FocusEventHandler, InputHTMLAttributes, KeyboardEventHandler, useEffect, useRef, useState} from 'react';
6
+ import {generateComponentId} from '../util/componentUtil';
6
7
  import {ChangeFunction, ErrorType, OrnamentShape} from '../util/interfaces';
7
8
  import {roundTo} from '../util/miscUtil';
8
9
  import DvrdInputController from "./dvrdInputController";
10
+ import {isPasting, positionIsAfterComma, textIsSelected} from "../util/inputUtil";
9
11
 
10
12
  interface Props {
11
13
  onChange: ChangeFunction<string | number>;
@@ -28,130 +30,114 @@ interface Props {
28
30
  error?: ErrorType;
29
31
  id?: string;
30
32
  placeholder?: string;
31
- multipleSeparators: boolean;
32
- wholeNumbers: boolean;
33
+ multipleSeparators?: boolean;
34
+ wholeNumbers?: boolean;
33
35
  asNumber?: boolean;
34
- asCurrency: boolean;
36
+ asCurrency?: boolean;
35
37
  autoSelect?: boolean;
36
38
  unControlled?: boolean;
39
+ disableSymbols?: boolean;
37
40
  }
38
41
 
39
- // TODO Add option to disable symbols (+,-)
42
+ export default function DVRDNumberInput(props: Props) {
43
+ const {
44
+ onKeyDown, inputProps, asCurrency, asNumber, wholeNumbers, disableSymbols, onChange, value, unControlled
45
+ } = props;
46
+ const [innerValue, setInnerValue] = useState(value);
47
+ const id = useRef(generateComponentId(props.id));
40
48
 
41
- interface State {
42
- value: number | string;
43
- }
49
+ function _onKeyDown(evt: React.KeyboardEvent<HTMLInputElement>) {
50
+ const {key} = evt,
51
+ numValue = Number(value);
52
+ // Allow only number and simple edit keys
53
+ const editKeys = /\d|Backspace|ArrowRight|ArrowLeft|ArrowUp|ArrowDown|Escape|Esc|Enter|[,.\-+]|Tab/;
54
+ if (!editKeys.test(key) && !isPasting(evt))
55
+ evt.preventDefault();
44
56
 
45
- export default class DvrdNumberInput extends PureComponent<Props, State> {
46
- static defaultProps = {
47
- asCurrency: false,
48
- wholeNumbers: false,
49
- multipleSeparators: false,
50
- }
57
+ // Handle Arrow events
58
+ if (['ArrowUp', 'ArrowDown'].includes(key)) {
59
+ evt.preventDefault();
60
+ if (!isNaN(numValue))
61
+ return onArrowEvent(evt, numValue);
62
+ }
51
63
 
52
- constructor(props: Props) {
53
- super(props);
54
- this.state = {
55
- value: props.value,
56
- };
64
+ // Handle event if no selected text
65
+ onKeyDownSelected(evt);
66
+
67
+ if (onKeyDown)
68
+ onKeyDown(evt);
57
69
  }
58
70
 
71
+ function onArrowEvent(evt: React.KeyboardEvent<HTMLInputElement>, numValue: number) {
72
+ const {key} = evt,
73
+ step = Number(inputProps?.step || asCurrency ? .01 : 1);
74
+ if (key === 'ArrowUp')
75
+ numValue += step;
76
+ else if (key === 'ArrowDown')
77
+ numValue -= step;
78
+ let stringValue = numValue.toString();
79
+ if (asCurrency) stringValue = roundTo(numValue, 2);
80
+ if (asNumber) change(Number(stringValue));
81
+ else change(stringValue);
82
+ }
59
83
 
60
- onKeyDown = (evt: React.KeyboardEvent) => {
61
- const {onKeyDown, asCurrency, wholeNumbers, inputProps, asNumber, multipleSeparators} = this.props;
62
- const {value} = this.state;
63
- const {key, target} = evt;
64
- let numValue = Number(value);
65
- // Allow only number and simple edit keys
66
- if (!/\d|Backspace|ArrowRight|ArrowLeft|ArrowUp|ArrowDown|Escape|Esc|Enter|[,.\-+]|Tab|Delete|Del/.test(key) &&
67
- !this.isPasting(evt))
84
+ function onKeyDownSelected(evt: React.KeyboardEvent<HTMLInputElement>) {
85
+ if (textIsSelected(evt)) return;
86
+ const {currentTarget, key} = evt,
87
+ position = currentTarget.selectionStart;
88
+ if (/[,.]/.test(key) && /[,.]/.test(value.toString()) && asCurrency)
89
+ // Prevent double points or commas
68
90
  evt.preventDefault();
69
91
 
70
- if (key === 'ArrowUp') {
92
+ if (wholeNumbers && /[.,]/.test(key))
71
93
  evt.preventDefault();
72
- if (!isNaN(numValue)) {
73
- const step = Number(inputProps?.step || asCurrency ? .01 : 1);
74
- numValue += step;
75
- let stringValue = numValue.toString();
76
- if (asCurrency) stringValue = roundTo(numValue, 2);
77
- if (asNumber) this.change(Number(stringValue), evt);
78
- else this.change(stringValue, evt);
79
- }
80
- return;
81
- }
82
- if (key === 'ArrowDown') {
94
+ else if (asCurrency && /[.,]\d{2}/.test(value.toString()) && /\d/.test(key) &&
95
+ positionIsAfterComma(position, value.toString()))
96
+ // Prevent more than 2 decimals when working with currencies
83
97
  evt.preventDefault();
84
- if (!isNaN(numValue)) {
85
- const step = Number(inputProps?.step || asCurrency ? .01 : 1);
86
- numValue -= step;
87
- let stringValue = numValue.toString();
88
- if (asCurrency) stringValue = roundTo(numValue, 2);
89
- if (asNumber) this.change(Number(stringValue), evt);
90
- else this.change(stringValue, evt);
91
- }
92
- return;
93
- }
94
98
 
95
- const textIsSelected = this.textIsSelected(evt), position = (target as HTMLInputElement).selectionStart;
96
- if (!textIsSelected) {
97
- if (/[,.]/.test(key) && /[,.]/.test(value.toString()) && !multipleSeparators)
98
- // Prevent double points or commas
99
- evt.preventDefault();
100
-
101
- if (wholeNumbers && /[.,]/.test(key))
102
- evt.preventDefault();
103
- else if (asCurrency && /[.,]\d{2}/.test(value.toString()) && /\d/.test(key) &&
104
- this.positionIsAfterComma(position, value.toString()))
105
- // Prevent more than 2 decimals when working with currencies
106
- evt.preventDefault();
107
-
108
- if ((position && position !== 0) && /[\-+]/.test(key))
109
- // Prevent - or + sign after first position
110
- evt.preventDefault();
111
- }
112
-
113
- if (onKeyDown !== undefined && onKeyDown !== null)
114
- onKeyDown(evt);
115
- };
99
+ if (/[\-+]/.test(key) && (disableSymbols || (position && position !== 0)))
100
+ // Prevent - or + sign completely or after first position
101
+ evt.preventDefault();
102
+ }
116
103
 
117
- onChange = (value: string, evt: React.ChangeEvent) => {
118
- const {asNumber} = this.props;
104
+ function _onChange(value: string, evt: React.ChangeEvent<HTMLInputElement>) {
105
+ value = value.replace(/ /g, '');
119
106
  if (!/^-?[\d.,]*$/.test(value)) evt.preventDefault();
120
107
  else {
121
- let changeValue: string | number = value;
122
- if (value.startsWith('.') || value.startsWith(',')) value = 0 + value;
123
- if (asNumber) changeValue = Number(value.replace(',', '.'));
124
- this.change(changeValue, evt);
108
+ if (value.startsWith('.') || value.startsWith(',')) value = '0' + value;
109
+ if (wholeNumbers)
110
+ // Only allow numbers and minus sign for negative numbers
111
+ value = value.replace(/[^0-9-]/g, '').trim();
112
+ if (disableSymbols)
113
+ value = value.replace(/[\-+]/g, '').trim();
114
+ if (asNumber) {
115
+ const numValue = Number(value.replace(',', '.'));
116
+ change(numValue, evt);
117
+ } else change(value, evt);
125
118
  }
126
- };
127
-
128
- change = (value: string | number, evt: React.ChangeEvent | React.KeyboardEvent) => {
129
- this.setState({value}, () => {
130
- this.props.onChange(value, evt as React.ChangeEvent);
131
- })
132
- };
133
-
134
- isPasting = (evt: React.KeyboardEvent) => {
135
- return (evt.metaKey || evt.ctrlKey) && evt.key === 'v';
136
- };
137
-
138
- positionIsAfterComma = (position: number | null, value: string) => {
139
- if (position === null) return true;
140
- if (value.includes('.')) return position > value.indexOf('.');
141
- if (value.includes(',')) return position > value.indexOf(',');
142
- return false;
143
- };
144
-
145
- textIsSelected = (evt: React.KeyboardEvent) => {
146
- const target = evt.target as HTMLInputElement, start = target.selectionStart, end = target.selectionEnd;
147
- return start != end;
148
- };
149
-
150
- render = () => {
151
- const value = this.props.unControlled ? this.state.value : this.props.value;
152
- return (
153
- <DvrdInputController {...this.props} value={value} onChange={this.onChange} onKeyDown={this.onKeyDown}
154
- unControlled={false}/>
155
- );
119
+ return value;
120
+ }
121
+
122
+ function change(value: number | string, evt?: React.ChangeEvent<HTMLInputElement>) {
123
+ setInnerValue(value);
124
+ onChange(value, evt);
125
+ }
126
+
127
+ function focusInput() {
128
+ document.getElementById(id.current + '-input')?.focus();
156
129
  }
130
+
131
+ useEffect(() => {
132
+ if (!unControlled) setInnerValue(value);
133
+ }, [value]);
134
+
135
+ useEffect(() => {
136
+ if (!props.disabled && props.autoFocus)
137
+ focusInput();
138
+ }, [props.disabled, props.autoFocus]);
139
+
140
+ return (
141
+ <DvrdInputController {...props} id={id.current} onChange={_onChange} onKeyDown={_onKeyDown} value={innerValue}/>
142
+ );
157
143
  }
@@ -0,0 +1,21 @@
1
+ /*
2
+ * Copyright (c) 2024. Dave van Rijn Development
3
+ */
4
+
5
+ export function isPasting(evt: React.KeyboardEvent): boolean {
6
+ return (evt.metaKey || evt.ctrlKey) && evt.key === 'v';
7
+ }
8
+
9
+ export function textIsSelected(evt: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>): boolean {
10
+ const {currentTarget} = evt,
11
+ start = currentTarget.selectionStart,
12
+ end = currentTarget.selectionEnd;
13
+ return start != end;
14
+ }
15
+
16
+ export function positionIsAfterComma(position: number | null, value: string): boolean {
17
+ if (position === null) return true;
18
+ if (value.includes('.')) return position > value.indexOf('.');
19
+ if (value.includes(',')) return position > value.indexOf(',');
20
+ return false;
21
+ }