x4js 2.3.10 → 2.3.12

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.
@@ -0,0 +1,361 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file autocomplete.ts
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ import { Component, ComponentEvent, ComponentEvents, EvChange } from '../../core/component';
18
+ import { EventCallback } from '../../core/core_events';
19
+ import { class_ns, kbNav } from '../../core/core_tools';
20
+
21
+ import { HBox } from '../boxes/boxes';
22
+ import { DropdownList } from '../combobox/combobox';
23
+ import { TextInputProps } from '../input/input';
24
+ import { SimpleText } from '../label/label';
25
+ import { ListboxID, ListItem } from '../listbox/listbox';
26
+ import { TextEdit, TextEditBase } from '../textedit/textedit';
27
+
28
+ import "./autocomplete.module.scss"
29
+
30
+ /**
31
+ * fired when suggestions are needed for a text
32
+ * setItems can be called later (async), late answers are ignored
33
+ */
34
+
35
+ export interface EvSearch extends ComponentEvent {
36
+ text: string;
37
+ setItems: ( items: ListItem[] ) => void;
38
+ }
39
+
40
+ /**
41
+ * fired when the value is validated
42
+ */
43
+
44
+ export interface EvAutoCompleteChange extends EvChange {
45
+ item: ListItem; // picked item, null for a free text
46
+ }
47
+
48
+ interface AutoCompleteEvents extends ComponentEvents {
49
+ search: EvSearch;
50
+ change: EvAutoCompleteChange;
51
+ }
52
+
53
+ export interface AutoCompleteProps extends Omit<TextInputProps,"type"|"change">, TextEditBase {
54
+ search: EventCallback<EvSearch>;
55
+ change?: EventCallback<EvAutoCompleteChange>;
56
+
57
+ /** only a picked item (or nothing) is accepted, default false: free text is kept */
58
+ strict?: boolean;
59
+
60
+ /** what is written in the field when an item is picked (default "text") */
61
+ itemValue?: "text" | "id";
62
+
63
+ /** delay between the last key and the search, in ms (default 250) */
64
+ delay?: number;
65
+
66
+ /** number of chars needed to start a search (default 1) */
67
+ minChars?: number;
68
+
69
+ /** custom renderer for the suggestions */
70
+ renderer?: ( item: ListItem ) => Component;
71
+ }
72
+
73
+ // suggestion as: id + text
74
+ const render_id_item = ( item: ListItem ) => {
75
+ return new HBox( {
76
+ cls: item.cls,
77
+ content: [
78
+ new SimpleText( { cls: "id", text: item.id+"" } ),
79
+ new SimpleText( { cls: "text", text: item.text } ),
80
+ ]
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Text field with suggestions asked on demand (search event).
86
+ *
87
+ * @example
88
+ * new AutoComplete( {
89
+ * label: "Customer",
90
+ * strict: true,
91
+ * search: async ( ev ) => {
92
+ * const rows = await api.searchCustomers( ev.text );
93
+ * ev.setItems( rows.map( r => ( { id: r.id, text: r.name } ) ) );
94
+ * },
95
+ * change: ( ev ) => console.log( ev.value, ev.item ),
96
+ * })
97
+ */
98
+
99
+ @class_ns( "x4" )
100
+ export class AutoComplete extends TextEdit<AutoCompleteEvents> {
101
+
102
+ private _popup: DropdownList;
103
+
104
+ private _strict: boolean;
105
+ private _by_id: boolean;
106
+ private _delay: number;
107
+ private _min_chars: number;
108
+
109
+ private _text: string; // last validated text
110
+ private _item: ListItem; // last picked item (null: free text)
111
+ private _seq: number; // search sequence, to ignore late answers
112
+ private _count: number; // number of suggestions
113
+
114
+ constructor( props: AutoCompleteProps ) {
115
+ // the change of the input (each key) starts the search, our change event is fired on validation
116
+ super( { ...props, type: "text", change: ( ) => this._on_input( ) } );
117
+
118
+ this._strict = !!props.strict;
119
+ this._by_id = props.itemValue=="id";
120
+ this._delay = props.delay ?? 250;
121
+ this._min_chars = props.minChars ?? 1;
122
+
123
+ this._text = this.getInput( ).getValue( );
124
+ this._item = null;
125
+ this._seq = 0;
126
+ this._count = 0;
127
+
128
+ this.mapPropEvents( props, 'change', 'search' );
129
+
130
+ this._popup = new DropdownList( {
131
+ cls: "autocomplete",
132
+ items: [],
133
+ renderer: props.renderer ?? ( this._by_id ? render_id_item : undefined )
134
+ } );
135
+
136
+ this._popup.on( "click", ( ev ) => {
137
+ const item = this._popup.getList( ).getItem( ev.context as ListboxID );
138
+ if( item ) {
139
+ this._pick( item );
140
+ }
141
+ });
142
+
143
+ this.getInput( ).addDOMEvent( "keydown", ( ev ) => this._on_key( ev ) );
144
+
145
+ this.addDOMEvent( "focusout", ( ev ) => this._on_focusout( ev ) );
146
+ }
147
+
148
+ /**
149
+ *
150
+ */
151
+
152
+ private _on_input( ) {
153
+ const text = this.getInput( ).getValue( );
154
+
155
+ // pending answers are obsolete
156
+ this._seq++;
157
+
158
+ if( text.length<this._min_chars ) {
159
+ this.clearTimeout( "search" );
160
+ this._popup.show( false );
161
+ return;
162
+ }
163
+
164
+ this.setTimeout( "search", this._delay, ( ) => this._search( text ) );
165
+ }
166
+
167
+ /**
168
+ *
169
+ */
170
+
171
+ private _search( text: string ) {
172
+ const seq = ++this._seq;
173
+
174
+ const setItems = ( items: ListItem[] ) => {
175
+ // a newer search was started (or the field was left)
176
+ if( seq!=this._seq ) {
177
+ return;
178
+ }
179
+
180
+ this._setItems( items ?? [] );
181
+ }
182
+
183
+ this.fire( "search", { text, setItems } );
184
+ }
185
+
186
+ private _setItems( items: ListItem[] ) {
187
+ const list = this._popup.getList( );
188
+ list.setItems( items );
189
+ this._count = items.length;
190
+
191
+ if( !items.length ) {
192
+ this._popup.show( false );
193
+ return;
194
+ }
195
+
196
+ this.showDropDown( );
197
+
198
+ // strict: Enter picks the first suggestion
199
+ if( this._strict ) {
200
+ list.navigate( kbNav.first );
201
+ }
202
+ }
203
+
204
+ /**
205
+ *
206
+ */
207
+
208
+ private _on_key( ev: KeyboardEvent ) {
209
+ const list = this._popup.getList( );
210
+ const open = this._popup.isOpen( );
211
+
212
+ switch( ev.key ) {
213
+ case "ArrowUp":
214
+ case "ArrowDown": {
215
+ if( open ) {
216
+ list.navigate( ev.key=="ArrowDown" ? kbNav.next : kbNav.prev );
217
+ }
218
+ else if( this._count ) {
219
+ this.showDropDown( );
220
+ }
221
+ else {
222
+ this.clearTimeout( "search" );
223
+ this._search( this.getInput( ).getValue( ) );
224
+ }
225
+
226
+ break;
227
+ }
228
+
229
+ case "Enter": {
230
+ const [sel] = open ? list.getSelection( ) : [];
231
+ const item = sel!==undefined ? list.getItem( sel ) : null;
232
+
233
+ if( !item ) {
234
+ // nothing to pick: the key is not for us
235
+ this._popup.show( false );
236
+ return;
237
+ }
238
+
239
+ this._pick( item );
240
+ break;
241
+ }
242
+
243
+ case "Escape": {
244
+ if( !open ) {
245
+ return;
246
+ }
247
+
248
+ this._popup.show( false );
249
+ break;
250
+ }
251
+
252
+ default: {
253
+ return;
254
+ }
255
+ }
256
+
257
+ ev.preventDefault( );
258
+ ev.stopPropagation( );
259
+ }
260
+
261
+ /**
262
+ *
263
+ */
264
+
265
+ private _on_focusout( ev: FocusEvent ) {
266
+ // focus is moving inside the control
267
+ const to = ev.relatedTarget as Node;
268
+ if( to && this.dom.contains( to ) ) {
269
+ return;
270
+ }
271
+
272
+ this.clearTimeout( "search" );
273
+ this._seq++;
274
+ this._popup.show( false );
275
+
276
+ const input = this.getInput( );
277
+ const text = input.getValue( );
278
+ if( text===this._text ) {
279
+ return;
280
+ }
281
+
282
+ // strict: back to the last validated value (but the field can be emptied)
283
+ if( this._strict && text!=="" ) {
284
+ input.setValue( this._text );
285
+ return;
286
+ }
287
+
288
+ this._commit( text, null );
289
+ }
290
+
291
+ /**
292
+ * an item was choosen in the suggestions
293
+ */
294
+
295
+ private _pick( item: ListItem ) {
296
+ const text = ( this._by_id ? item.id : item.text ) + "";
297
+
298
+ this.clearTimeout( "search" );
299
+ this._seq++;
300
+
301
+ this.getInput( ).setValue( text );
302
+ this._popup.show( false );
303
+
304
+ this._commit( text, item );
305
+ }
306
+
307
+ private _commit( text: string, item: ListItem ) {
308
+ const changed = text!==this._text || item?.id!==this._item?.id;
309
+
310
+ this._text = text;
311
+ this._item = item;
312
+
313
+ if( changed ) {
314
+ this.fire( "change", { value: text, item } );
315
+ }
316
+ }
317
+
318
+ /**
319
+ *
320
+ */
321
+
322
+ showDropDown( ) {
323
+ if( this.isDisabled() || this._popup.isOpen( ) ) {
324
+ return;
325
+ }
326
+
327
+ const rc = ( this.query( ":scope > #edit" ) ?? this ).getBoundingRect( );
328
+ this._popup.setStyleValue( "minWidth", rc.width+"px" );
329
+ this._popup.displayNear( rc, "top left", "bottom left", {x:0,y:6} );
330
+ }
331
+
332
+ /**
333
+ * change the text of the field (no item is selected, no event)
334
+ */
335
+
336
+ override setValue( value: string ) {
337
+ super.setValue( value );
338
+
339
+ this._text = this.getInput( ).getValue( );
340
+ this._item = null;
341
+ }
342
+
343
+ /**
344
+ * select an item as if it was picked in the suggestions (no event)
345
+ */
346
+
347
+ setSelection( item: ListItem ) {
348
+ super.setValue( ( this._by_id ? item.id : item.text ) + "" );
349
+
350
+ this._text = this.getInput( ).getValue( );
351
+ this._item = item;
352
+ }
353
+
354
+ /**
355
+ * @returns the id of the picked item, undefined if the text does not come from the suggestions
356
+ */
357
+
358
+ getSelection( ): ListboxID {
359
+ return this.getInput( ).getValue( )===this._text ? this._item?.id : undefined;
360
+ }
361
+ }
@@ -17,6 +17,7 @@
17
17
  import { asap, class_ns, isArray, isNumber } from '../../core/core_tools';
18
18
  import { Component, ComponentEvents, ComponentProps, EvSelectionChange } from "../../core/component"
19
19
  import { EventCallback } from '../../core/core_events';
20
+ import { HSizer, VSizer } from '../sizers/sizer';
20
21
 
21
22
  import "./boxes.module.scss";
22
23
 
@@ -91,6 +92,71 @@ export class VBox<P extends BoxProps=BoxProps,E extends ComponentEvents=Componen
91
92
  }
92
93
 
93
94
 
95
+ // :: SPLITBOX ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
96
+
97
+ export interface SplitBoxProps extends Omit<BoxProps,"content"> {
98
+ /** Panels to lay out, a sizer is inserted between each of them. */
99
+ content: Component[];
100
+ }
101
+
102
+ /**
103
+ * insert a sizer between each panel.
104
+ * the sizer resizes the panel that is not flex (the flex one takes the remaining space),
105
+ * when both are of the same kind, the previous panel is resized.
106
+ */
107
+
108
+ function split_content( panels: Component[], horz: boolean ) {
109
+ const els = panels?.filter( x => !!x ) ?? [];
110
+ const res: Component[] = [];
111
+
112
+ els.forEach( ( p, i ) => {
113
+ if( i>0 ) {
114
+ const prev = els[i-1];
115
+ const next = prev.hasClass( "x4flex" ) && !p.hasClass( "x4flex" );
116
+ res.push( horz ? new HSizer( next ) : new VSizer( next ) );
117
+ }
118
+
119
+ res.push( p );
120
+ });
121
+
122
+ return res;
123
+ }
124
+
125
+ /**
126
+ * A horizontal box where panels are separated by sizers: panels are side by side
127
+ * (like in a {@link HBox}) and the user can change their width.
128
+ * The CSS class for this component is automatically generated as `x4hsplitbox`.
129
+ *
130
+ * @example
131
+ * new HSplitBox( {
132
+ * content: [
133
+ * new Treeview( { width: 250, ... } ), // resized by the sizer
134
+ * new Panel( { flex: 1, ... } ), // takes the remaining space
135
+ * ]
136
+ * })
137
+ */
138
+
139
+ @class_ns( "x4" )
140
+ export class HSplitBox<P extends SplitBoxProps=SplitBoxProps,E extends ComponentEvents=ComponentEvents> extends HBox<P,E> {
141
+ constructor( props: P ) {
142
+ super( { ...props, content: split_content( props.content, true ) } );
143
+ }
144
+ }
145
+
146
+ /**
147
+ * A vertical box where panels are separated by sizers: panels are stacked
148
+ * (like in a {@link VBox}) and the user can change their height.
149
+ * The CSS class for this component is automatically generated as `x4vsplitbox`.
150
+ */
151
+
152
+ @class_ns( "x4" )
153
+ export class VSplitBox<P extends SplitBoxProps=SplitBoxProps,E extends ComponentEvents=ComponentEvents> extends VBox<P,E> {
154
+ constructor( props: P ) {
155
+ super( { ...props, content: split_content( props.content, false ) } );
156
+ }
157
+ }
158
+
159
+
94
160
  type ContentBuilder = ( ) => Component;
95
161
 
96
162
 
@@ -14,12 +14,12 @@
14
14
  * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
15
  **/
16
16
 
17
- import { Menu, MenuElement, MenuProps } from 'x4js';
18
17
  import { Component, ComponentEvents, ComponentProps, EvClick } from "../../core/component"
19
18
  import { EventCallback } from '../../core/core_events';
20
19
  import { class_ns, isFunction, UnsafeHtml } from '../../core/core_tools';
21
20
 
22
21
  import { Icon } from "../icon/icon"
22
+ import { Menu, MenuElement } from '../menu/menu';
23
23
 
24
24
  import "./button.module.scss";
25
25
 
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ___ ___ __
3
+ * \ \/ / / _
4
+ * \ / /_| |_
5
+ * / \____ _|
6
+ * /__/\__\ |_|
7
+ *
8
+ * @file chart.module.scss
9
+ * @author Etienne Cochard
10
+ *
11
+ * @copyright (c) 2026 R-libre ingenierie
12
+ *
13
+ * Use of this source code is governed by an MIT-style license
14
+ * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
+ **/
16
+
17
+ :root {
18
+ --chart-background: white;
19
+ --chart-text: #52514e;
20
+ --chart-grid: #e6e5e0;
21
+ --chart-axis: #b5b4ae;
22
+
23
+ // series colors, used in this order
24
+ --chart-color-1: #2a78d6;
25
+ --chart-color-2: #eb6834;
26
+ --chart-color-3: #1baf7a;
27
+ --chart-color-4: #eda100;
28
+ --chart-color-5: #e87ba4;
29
+ --chart-color-6: #008300;
30
+ --chart-color-7: #4a3aa7;
31
+ --chart-color-8: #e34948;
32
+ }
33
+
34
+ .x4chart {
35
+ display: block;
36
+ background-color: var( --chart-background );
37
+
38
+ text {
39
+ font-size: 10px;
40
+ fill: var( --chart-text );
41
+ user-select: none;
42
+
43
+ &.name {
44
+ font-weight: 500;
45
+ }
46
+ }
47
+
48
+ .grid,
49
+ .base {
50
+ fill: none;
51
+ stroke-width: 1px;
52
+ shape-rendering: crispEdges;
53
+ }
54
+
55
+ .grid {
56
+ stroke: var( --chart-grid );
57
+ }
58
+
59
+ .base {
60
+ stroke: var( --chart-axis );
61
+ }
62
+
63
+ // --chart-c is the color of the element (palette slot, or serie.color)
64
+ @for $i from 1 through 8 {
65
+ .c#{$i} {
66
+ --chart-c: var( --chart-color-#{$i} );
67
+ }
68
+ }
69
+
70
+ .mark,
71
+ .bar,
72
+ .slice {
73
+ fill: var( --chart-c );
74
+ }
75
+
76
+ .slice {
77
+ stroke: var( --chart-background );
78
+ stroke-width: 2px;
79
+ stroke-linejoin: round;
80
+ }
81
+
82
+ .ring {
83
+ fill: none;
84
+ stroke: var( --chart-c );
85
+ }
86
+
87
+ .line {
88
+ fill: none;
89
+ stroke: var( --chart-c );
90
+ stroke-width: 2px;
91
+ stroke-linejoin: round;
92
+ stroke-linecap: round;
93
+ }
94
+
95
+ .area {
96
+ fill: var( --chart-c );
97
+ fill-opacity: 0.15;
98
+ stroke: none;
99
+ }
100
+
101
+ .dot {
102
+ fill: var( --chart-c );
103
+ stroke: var( --chart-background );
104
+ stroke-width: 2px;
105
+ }
106
+
107
+ .bar,
108
+ .slice,
109
+ .ring,
110
+ .dot {
111
+ transition: opacity 0.15s ease;
112
+
113
+ &:hover {
114
+ opacity: 0.75;
115
+ }
116
+ }
117
+
118
+ // hover point of a line without dots
119
+ .dot.ghost {
120
+ opacity: 0;
121
+
122
+ &:hover {
123
+ opacity: 1;
124
+ }
125
+ }
126
+ }