x4js 2.3.10 → 2.3.11
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/aicontext.md +70 -3
- package/aiguide.md +60 -0
- package/package.json +1 -1
- package/src/components/autocomplete/autocomplete.module.scss +28 -0
- package/src/components/autocomplete/autocomplete.ts +361 -0
- package/src/components/boxes/boxes.ts +66 -0
- package/src/components/button/button.ts +1 -1
- package/src/components/chart/chart.module.scss +126 -0
- package/src/components/chart/chart.ts +758 -0
- package/src/components/combobox/combobox.ts +5 -5
- package/src/components/components.ts +2 -0
- package/src/components/header/header.ts +38 -3
- package/src/components/listbox/listbox.module.scss +4 -5
- package/src/components/listbox/listbox.ts +1 -1
- package/src/components/select/select.module.scss +26 -17
- package/src/components/select/select.ts +60 -10
- package/src/components/spreadsheet/spreadsheet.module.scss +31 -0
- package/src/components/spreadsheet/spreadsheet.ts +522 -11
- package/src/components/textedit/textedit.ts +4 -4
- package/src/core/core_svg.ts +2 -2
- package/readme.npm.md +0 -248
package/aicontext.md
CHANGED
|
@@ -26,7 +26,7 @@ import { ... } from 'x4js' // src/x4.ts
|
|
|
26
26
|
CoreElement<E> named timers + event surface (no DOM)
|
|
27
27
|
├── Component<P,E> base DOM component
|
|
28
28
|
│ ├── Box<P,E>
|
|
29
|
-
│ │ ├── HBox / VBox
|
|
29
|
+
│ │ ├── HBox / VBox → HSplitBox / VSplitBox
|
|
30
30
|
│ │ ├── StackBox → AssistBox
|
|
31
31
|
│ │ ├── GridBox / MasonryBox
|
|
32
32
|
│ │ ├── Form
|
|
@@ -35,10 +35,11 @@ CoreElement<E> named timers + event surface (no DOM)
|
|
|
35
35
|
│ │ ├── BtnGroup, Tabs, Video, Saturation
|
|
36
36
|
│ ├── Button, Input, Icon, Label, SimpleText, Checkbox, Radio, Link
|
|
37
37
|
│ ├── Listbox, Gridview, Treeview, Select, Combobox, Slider, Gauge
|
|
38
|
-
│ ├── Progress, TickLine, Canvas, Image,
|
|
38
|
+
│ ├── Progress, TickLine, Canvas, Image, Spreadsheet, MonacoEditor
|
|
39
|
+
│ ├── SvgComponent → Chart
|
|
39
40
|
│ ├── CSizer → HSizer / VSizer
|
|
40
41
|
│ ├── FileDialog, ScrollView, Viewport, Flex, Space
|
|
41
|
-
│ ├── HBox-based: ColorInput, Rating, Switch, Keyboard, TextEdit, Breadcrumbs
|
|
42
|
+
│ ├── HBox-based: ColorInput, Rating, Switch, Keyboard, TextEdit → AutoComplete, Breadcrumbs
|
|
42
43
|
│ └── VBox-based: Calendar, ColorPicker, FileDrop, Panel, PropertyGrid, TextArea
|
|
43
44
|
└── Application<E> singleton, no DOM of its own
|
|
44
45
|
|
|
@@ -608,6 +609,26 @@ class Combobox extends Component {
|
|
|
608
609
|
}
|
|
609
610
|
```
|
|
610
611
|
|
|
612
|
+
### `AutoComplete` — text field with on-demand suggestions
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
interface AutoCompleteProps extends TextEditProps { // label, labelWidth, required, placeholder, value...
|
|
616
|
+
search: (ev: EvSearch) => void // required; ev.text, ev.setItems(items) — may be called async
|
|
617
|
+
change? // EvAutoCompleteChange { value, item /* null = free text */ }
|
|
618
|
+
strict?: boolean // only a picked item (or empty) is accepted; default false
|
|
619
|
+
itemValue?: "text" | "id" // what goes into the field when an item is picked; default "text"
|
|
620
|
+
delay?: number // ms before search, default 250
|
|
621
|
+
minChars?: number // default 1
|
|
622
|
+
renderer?: (item: ListItem) => Component
|
|
623
|
+
}
|
|
624
|
+
class AutoComplete extends TextEdit {
|
|
625
|
+
getSelection(): ListboxID // id of the picked item, undefined for a free text
|
|
626
|
+
setSelection(item: ListItem): void; showDropDown(): void
|
|
627
|
+
}
|
|
628
|
+
// Use Combobox for a fixed, known list; AutoComplete when suggestions come from a search (server).
|
|
629
|
+
// In a Form the value is the field content (text or id, per itemValue).
|
|
630
|
+
```
|
|
631
|
+
|
|
611
632
|
### `ColorInput`
|
|
612
633
|
|
|
613
634
|
```ts
|
|
@@ -710,12 +731,29 @@ interface SpreadsheetProps extends ComponentProps {
|
|
|
710
731
|
columns: SpreadsheetColumn[] // required
|
|
711
732
|
footer?: boolean
|
|
712
733
|
rowClassifier?: RowClassifier
|
|
734
|
+
enterMove?: "down" | "right" | "none" // selection move after Enter in a cell editor (default "down")
|
|
713
735
|
click?, dblClick?, contextMenu?, selectionChange?
|
|
736
|
+
cellChange? // EvCellChange, fired before an edited value is written to the store
|
|
737
|
+
}
|
|
738
|
+
interface SpreadsheetColumn { // GridColumn +
|
|
739
|
+
editable?: boolean | ((row, col) => boolean) // inline edition (off by default), col = column index
|
|
740
|
+
editor?: (row, col, value) => Component // custom editor, must implement queryInterface("form-element")
|
|
714
741
|
}
|
|
742
|
+
interface EvCellChange { row; col /* index */; colId /* store key */; value /* writable */; oldValue }
|
|
715
743
|
class Spreadsheet extends Component {
|
|
716
744
|
getSelection(): /* cell */; navigate(sens: kbNav): boolean; lock(lock): void
|
|
745
|
+
editCell(row, col, clear = false): boolean // cell must be visible
|
|
746
|
+
stopEdit(commit = true): void; isEditing(): boolean
|
|
717
747
|
}
|
|
718
748
|
|
|
749
|
+
// Inline edition: F2 / Enter / double-click / typing starts, Enter / Tab / click elsewhere validates, Escape cancels,
|
|
750
|
+
// Delete clears, editable checkboxes toggle on click or Space. Default editor is an Input matching the column type.
|
|
751
|
+
// cellChange: ev.preventDefault() refuses the value, ev.value can be replaced.
|
|
752
|
+
// Select / Combobox in a cell (the store holds the id, use `formatter` to display the text):
|
|
753
|
+
// { id: 2, title: "Status", width: 120, editable: true,
|
|
754
|
+
// formatter: ( id ) => states.find( x => x.id===id )?.text,
|
|
755
|
+
// editor: ( row, col, value ) => new Select( { items: states, value } ) }
|
|
756
|
+
|
|
719
757
|
// Store: in-memory cell store (extends CoreElement)
|
|
720
758
|
class Store extends CoreElement {
|
|
721
759
|
setData(row, col, data): void; getData(row, col): any; hasData(row, col?): boolean
|
|
@@ -724,6 +762,28 @@ class Store extends CoreElement {
|
|
|
724
762
|
}
|
|
725
763
|
```
|
|
726
764
|
|
|
765
|
+
### `Chart` — minimal svg chart (line, area, bar, pie, donut)
|
|
766
|
+
|
|
767
|
+
```ts
|
|
768
|
+
interface ChartProps extends SvgProps {
|
|
769
|
+
type?: "line" | "area" | "bar" | "pie" | "donut" // default "line"
|
|
770
|
+
series: ChartSerie[] // required; pie & donut use the first serie
|
|
771
|
+
labels?: string[] // default: 1, 2, 3...
|
|
772
|
+
xaxis?: ChartAxis; yaxis?: ChartAxis // { name?, ticks?, min?, max?, step? }, all computed by default
|
|
773
|
+
legend?: boolean // default: shown when 2+ named series
|
|
774
|
+
stacked?: boolean
|
|
775
|
+
formatter?: (value: number) => string // default: chartFormatUnit (1.5k, 2M...)
|
|
776
|
+
click? // EvChartClick { serie, index, value }
|
|
777
|
+
}
|
|
778
|
+
interface ChartSerie { name?; values: number[] /* undefined = hole */; color?; fill?; dots? }
|
|
779
|
+
class Chart extends SvgComponent {
|
|
780
|
+
setType(type): void; setSeries(series, labels?): void; setLabels(labels): void
|
|
781
|
+
setTicks(sens: "x" | "y", ticks: ChartTick[]): void
|
|
782
|
+
}
|
|
783
|
+
// Colors: CSS variables --chart-color-1..8 (or serie.color). Size it with width/height/flex like any component.
|
|
784
|
+
// Minimal by design: no dual axis, mixed types, time axis, zoom or animation — use a dedicated library for those.
|
|
785
|
+
```
|
|
786
|
+
|
|
727
787
|
### `PropertyGrid`
|
|
728
788
|
|
|
729
789
|
```ts
|
|
@@ -894,6 +954,13 @@ class Form extends Box {
|
|
|
894
954
|
## Sizers / viewport / misc
|
|
895
955
|
|
|
896
956
|
```ts
|
|
957
|
+
// PREFERRED for resizable panels: HSplitBox / VSplitBox (sizers are inserted automatically between panels).
|
|
958
|
+
// The panel with `flex` takes the remaining space, its neighbour is the one resized.
|
|
959
|
+
class HSplitBox extends HBox // panels side by side, resizable width: { content: Component[] }
|
|
960
|
+
class VSplitBox extends VBox // panels stacked, resizable height: { content: Component[] }
|
|
961
|
+
// new HSplitBox( { content: [ new Treeview( { width: 250 } ), new Panel( { flex: 1 } ) ] } )
|
|
962
|
+
|
|
963
|
+
// Low level: only place sizers by hand when a SplitBox does not fit (popup edges, header cells...).
|
|
897
964
|
class CSizer extends Component // base splitter
|
|
898
965
|
class HSizer extends CSizer // horizontal splitter
|
|
899
966
|
class VSizer extends CSizer // vertical splitter
|
package/aiguide.md
CHANGED
|
@@ -132,6 +132,23 @@ super();
|
|
|
132
132
|
|
|
133
133
|
when the component expects props.
|
|
134
134
|
|
|
135
|
+
When writing a custom component, pass the whole props object: do not remove your own values from it before calling `super`. The base class keeps what it receives in `this.props`, so they stay readable later.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// avoid: search and strict are lost for this.props
|
|
139
|
+
const { search, strict, ...base } = props;
|
|
140
|
+
super(base);
|
|
141
|
+
|
|
142
|
+
// prefer: override only what must not reach the parent
|
|
143
|
+
super({ ...props, type: "text" });
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Connect the event callbacks given in props with `mapPropEvents`, not with hand-written `if (props.x) this.on(...)`:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
this.mapPropEvents(props, "change", "search");
|
|
150
|
+
```
|
|
151
|
+
|
|
135
152
|
For derived application components, prefer building the component's main content explicitly after `super(props)`.
|
|
136
153
|
|
|
137
154
|
Ordinary nested x4 components may naturally use their `content` property where convenient.
|
|
@@ -192,6 +209,10 @@ Component objects
|
|
|
192
209
|
Real DOM
|
|
193
210
|
```
|
|
194
211
|
|
|
212
|
+
`addDOMEvent` belongs to custom components (a canvas-based widget, for example).
|
|
213
|
+
|
|
214
|
+
On standard components, use their props and events.
|
|
215
|
+
|
|
195
216
|
---
|
|
196
217
|
|
|
197
218
|
## 6. Objects vs JSX
|
|
@@ -327,6 +348,12 @@ export const deviceModel = new DeviceModel();
|
|
|
327
348
|
|
|
328
349
|
A model may encapsulate server access directly.
|
|
329
350
|
|
|
351
|
+
Views should never call `fetch`.
|
|
352
|
+
|
|
353
|
+
All server access goes through model classes deriving from one base class that owns the single `fetch` call (timeout, network failure, HTTP status, expired session, fetch mask).
|
|
354
|
+
|
|
355
|
+
Create one model per functional domain. It normally corresponds to one URL root.
|
|
356
|
+
|
|
330
357
|
Keep responsibilities simple:
|
|
331
358
|
|
|
332
359
|
```text id="t39sbp"
|
|
@@ -467,6 +494,18 @@ Do not introduce a JavaScript theme abstraction simply to mirror CSS variables.
|
|
|
467
494
|
|
|
468
495
|
Presentation belongs in CSS when CSS already solves the problem.
|
|
469
496
|
|
|
497
|
+
CSS styles a component; it never removes behaviour.
|
|
498
|
+
|
|
499
|
+
Do not hide a control a component creates: configure the component through its props (for example `closable: false`).
|
|
500
|
+
|
|
501
|
+
If the props cannot express it, say so instead of working around it.
|
|
502
|
+
|
|
503
|
+
To retheme an application, redefine the primary colour scale (`--color-primary-a5` … `--color-primary-a90`) once per theme.
|
|
504
|
+
|
|
505
|
+
Do not recolour components one by one, and do not override the variables derived from the scale.
|
|
506
|
+
|
|
507
|
+
A dark theme gets its own scale.
|
|
508
|
+
|
|
470
509
|
---
|
|
471
510
|
|
|
472
511
|
## 19. Browser and TypeScript capabilities
|
|
@@ -578,6 +617,27 @@ The goal is to build the application with the smallest set of mechanisms that ke
|
|
|
578
617
|
|
|
579
618
|
---
|
|
580
619
|
|
|
620
|
+
## 23. Translations
|
|
621
|
+
|
|
622
|
+
x4 starts in French.
|
|
623
|
+
|
|
624
|
+
Application texts are declared with `addTranslation` for each language.
|
|
625
|
+
|
|
626
|
+
Select the language before any component is built, since a component reads its texts at construction.
|
|
627
|
+
|
|
628
|
+
Call `selectLanguage` after `addTranslation`:
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
addTranslation("fr", { /* ... */ });
|
|
632
|
+
addTranslation("en", { /* ... */ });
|
|
633
|
+
|
|
634
|
+
selectLanguage("en");
|
|
635
|
+
|
|
636
|
+
// only now build the application components
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
581
641
|
## Final rule
|
|
582
642
|
|
|
583
643
|
When generating x4 code:
|
package/package.json
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ___ ___ __
|
|
3
|
+
* \ \/ / / _
|
|
4
|
+
* \ / /_| |_
|
|
5
|
+
* / \____ _|
|
|
6
|
+
* /__/\__\ |_|
|
|
7
|
+
*
|
|
8
|
+
* @file autocomplete.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
|
+
// the field itself is a textedit
|
|
18
|
+
|
|
19
|
+
.x4dropdownlist.autocomplete {
|
|
20
|
+
.x4item {
|
|
21
|
+
gap: 8px;
|
|
22
|
+
|
|
23
|
+
.id {
|
|
24
|
+
font-weight: 500;
|
|
25
|
+
min-width: 4em;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -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
|
|