x4js 2.3.13 → 2.3.15

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 CHANGED
@@ -46,10 +46,11 @@ CoreElement<E> named timers + event surface (no DOM)
46
46
 
47
47
  EventSource<E> standalone event registry
48
48
  ├── DataStore<T>
49
- └── Router
49
+ ├── Router
50
+ └── StateManager<T>
50
51
 
51
52
  CoreElement-based (non-DOM): DataProxy, DataView, Store
52
- Standalone: Rect, Timer, Color, DataModel, StateManager, Stylesheet, ComputedStyle,
53
+ Standalone: Rect, Timer, Color, DataModel, Stylesheet, ComputedStyle,
53
54
  SvgBuilder, X4PDFBuilder, x4_react, CMover, UnsafeHtml (extends String)
54
55
  ```
55
56
 
@@ -130,7 +131,7 @@ interface ComponentProps {
130
131
  ns?: string // SVG/MathML namespace
131
132
  cls?: string // extra CSS classes
132
133
  id?: string
133
- style?: Partial<CSSStyleDeclaration>
134
+ style?: StyleProps // numbers get "px" except unitless
134
135
  attrs?: Record<string, string | number | boolean>
135
136
  content?: ComponentContent
136
137
  dom_events?: GlobalDOMEvents
@@ -188,7 +189,7 @@ getInternalData<T>(name: string|symbol): T
188
189
  ### Styles
189
190
 
190
191
  ```ts
191
- setStyle(style: Partial<CSSStyleDeclaration>): this // numbers auto-suffixed "px" except unitless
192
+ setStyle(style: StyleProps): this // numbers auto-suffixed "px" except unitless
192
193
  setStyleValue<K>(name: K, value): this
193
194
  getStyleValue<K>(name: K): CSSStyleDeclaration[K]
194
195
  setWidth(w: number|string): void
@@ -633,7 +634,20 @@ class AutoComplete extends TextEdit {
633
634
  ### `ColorInput`
634
635
 
635
636
  ```ts
636
- class ColorInput extends HBox {} // queryInterface("form-element")
637
+ interface ColorInputProps extends BoxProps {
638
+ color: Color | string // null: no color shown
639
+ name?: string
640
+ format?: "rgb" | "hex" // text of the field, "rgb" by default
641
+ picker?: boolean // a click on the swatch opens a ColorPicker
642
+ swatches?: (Color|string)[] | (() => (Color|string)[]) // a function is called each time the picker opens
643
+ nullable?: boolean // "no color" can be chosen
644
+ change?: EventCallback<EvColorChange> // user changes only, not setColor
645
+ }
646
+ class ColorInput extends HBox { // queryInterface("form-element"): "#rrggbb" or null
647
+ getColor(): Color; getValue(): string
648
+ setColor(color: Color | string): void; setSwatches(swatches): void
649
+ }
650
+ interface EvColorChange extends EvChange { color: Color; value: string } // "#rrggbb" or "#rrggbbaa"; both null for "no color"
637
651
  ```
638
652
 
639
653
  ### `FileDrop` / `FileDialog`
@@ -796,6 +810,9 @@ class PropertyGrid extends VBox {
796
810
  setItems(groups: PropertyGroup[]): void
797
811
  setPropValue(name: string, value): void
798
812
  }
813
+ // PropertyValue.type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'color' | 'label' | 'button'
814
+ // 'color': the editor is a ColorInput (hex, with picker); swatches?: string[] | (() => string[]), nullable?: boolean,
815
+ // callback(name, value) receives "#rrggbb", or null for "no color"
799
816
  ```
800
817
 
801
818
  ---
@@ -978,7 +995,9 @@ class Video extends Box {
978
995
  set stream(stream: MediaStream)
979
996
  }
980
997
  class Calendar extends VBox { getDate(): Date; setDate(date: Date): void }
981
- class ColorPicker extends VBox // queryInterface("form-element")
998
+ class ColorPicker extends VBox { // props: color, swatches?, nullable?; "change": EvColorChange, also fired once when built
999
+ getColor(): Color; setColor(color: Color | string): void; setSwatches(swatches): void
1000
+ }
982
1001
  class Panel extends VBox { setTitle(title: string): void }
983
1002
  class Breadcrumbs extends HBox { setItems(elements): void }
984
1003
  class Keyboard extends HBox // virtual keyboard
@@ -1259,17 +1278,36 @@ initTooltips(): void // initialize the global
1259
1278
 
1260
1279
  ---
1261
1280
 
1262
- ## State (`core_state`) — Experimental
1281
+ ## State (`core_state`)
1263
1282
 
1264
1283
  ```ts
1265
- class StateManager {
1266
- constructor(initialState)
1267
- getState(path: string, defaultValue?): any
1268
- setState(path: string, value, context?): void
1284
+ makeState<T>(initialState: T): StateProxy<T> // the data itself + on / off / once / watch
1285
+
1286
+ const state = makeState({ count: 0, user: { name: '' }, items: [1, 2, 3] })
1287
+ state.on('change', e => ...) // EvStateChange { path, value }
1288
+ state.watch('user', e => ...) // 'user', anything below it, or a parent replaced; returns { off() }
1289
+ state.count++ // path "count"
1290
+ state.user.name = 'x' // path "user.name"
1291
+ state.items.push(4) // path "items[3]"
1292
+
1293
+ class StateManager<T> extends EventSource<StateEvents> {
1294
+ constructor(initialState: T)
1295
+ proxify(): StateProxy<T>
1296
+ on(name, listener): { off(): void }
1297
+ off(name, listener): void
1298
+ once(name, listener): { off(): void }
1299
+ watch(path: string, cb: (ev: EvStateChange) => void): { off(): void }
1269
1300
  }
1270
- // NOTE: marked @Experimental in the sources.
1301
+
1302
+ // Component (protected): a state kept in localStorage under "x4@persist:<name>",
1303
+ // saved 500 ms after a change
1304
+ loadPState(name: string, defaults: Record<string, any>): any
1271
1305
  ```
1272
1306
 
1307
+ - Plain objects and arrays are proxied when read; Date, Map, Set and class instances are not.
1308
+ - Setting a property to the value it already has fires nothing.
1309
+ - Reading a property that does not exist logs an error and returns `undefined`.
1310
+
1273
1311
  ---
1274
1312
 
1275
1313
  ## Common patterns
@@ -1469,7 +1507,6 @@ Optional keys: `external`, `define`, `esbuild`, `dev` (`host`, `port`, `https`,
1469
1507
  - The default language is `fr`. Call `selectLanguage('en')` to switch to English.
1470
1508
  - `Gridview` is backed by a `DataStore`; `Spreadsheet` is backed by a `Store` (different, cell-based).
1471
1509
  - `DataRecordID` is currently typed `any`; `strictNullChecks` is intentionally off in the recommended `tsconfig.json`.
1472
- - `StateManager` (`core_state`) is marked **Experimental** — verify before relying on it.
1473
1510
  - `MonacoEditor` requires `MonacoEditor.start()` (async) before use; register extra typings with `MonacoEditor.addTypelib(name, code)`.
1474
1511
  - `x4_react.create_element` is the JSX pragma;
1475
1512
  - `unbubbleEvents` is the set of DOM events that do not bubble through the x4 component tree.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.13",
3
+ "version": "2.3.15",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -17,7 +17,8 @@
17
17
  :root {
18
18
  --colorinput-btn-background: inherit;
19
19
  --colorinput-btn-color: var( --text-secondary );
20
- --colorinput-btn-color-hover: var( --text-primary );
20
+ --colorinput-btn-color-hover: var( --text-primary );
21
+ --colorinput-popup-background: var( --background-primary );
21
22
  }
22
23
 
23
24
  .x4colorinput {
@@ -33,6 +34,27 @@
33
34
  margin: 4px;
34
35
  width: 1rem;
35
36
  height: 1rem;
37
+
38
+ // props.picker: a click opens the color picker. It is a button:
39
+ // wide enough to be aimed at.
40
+ &.picker {
41
+ flex-shrink: 0;
42
+ width: 2.4rem;
43
+ height: 1.4rem;
44
+ border: 1px solid var( --border );
45
+ border-radius: 3px;
46
+ cursor: pointer;
47
+
48
+ &:hover {
49
+ border-color: var( --border-focus );
50
+ }
51
+ }
52
+
53
+ // props.nullable, no color: crossed out
54
+ &.none {
55
+ border: 1px solid var( --border );
56
+ background: linear-gradient( to top right, transparent 45%, #d04040 45%, #d04040 55%, transparent 55% );
57
+ }
36
58
  }
37
59
 
38
60
  input {
@@ -42,14 +64,19 @@
42
64
  flex-grow: 1;
43
65
  }
44
66
 
67
+ // the eye dropper: as high as the field, and wide enough to be aimed at
45
68
  .x4button {
69
+ flex-shrink: 0;
70
+ align-self: stretch;
46
71
  margin: 0;
47
- padding: 0;
72
+ padding: 0 6px;
48
73
  outline: none;
49
74
  background-color: var( --combobox-btn-background );
50
75
  color: var( --colorinput-btn-color );
51
76
 
52
77
  #icon {
78
+ width: 1.3em;
79
+ height: 1.3em;
53
80
  color: var( --colorinput-btn-color );
54
81
  }
55
82
 
@@ -62,4 +89,11 @@
62
89
  //color: var( --color-10 );
63
90
  }
64
91
  }
92
+ }
93
+
94
+ // the color picker opened from the swatch (props.picker)
95
+ .x4colorinput-popup {
96
+ background-color: var( --colorinput-popup-background );
97
+ border: 1px solid var( --border );
98
+ box-shadow: 0 4px 12px rgba( 0, 0, 0, 0.3 );
65
99
  }
@@ -1,40 +1,70 @@
1
- /**
1
+ /**
2
2
  * ___ ___ __
3
3
  * \ \/ / / _
4
4
  * \ / /_| |_
5
- * / \____ _|
5
+ * / \____ _|
6
6
  * /__/\__\ |_|
7
- *
7
+ *
8
8
  * @file colorinput.ts
9
- * @author Etienne Cochard
10
- *
9
+ * @author Etienne Cochard
10
+ *
11
11
  * @copyright (c) 2026 R-libre ingenierie
12
12
  *
13
- * Use of this source code is governed by an MIT-style license
13
+ * Use of this source code is governed by an MIT-style license
14
14
  * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
15
  **/
16
16
 
17
- import { Component } from '../../core/component';
18
- import { isFeatureAvailable, class_ns } from '../../core/core_tools';
17
+ import { Component, ComponentEvents } from '../../core/component';
18
+ import { EventCallback } from '../../core/core_events';
19
+ import { isFeatureAvailable, class_ns, isFunction, IComponentInterface, IFormElement } from '../../core/core_tools';
19
20
  import { Color } from '../../core/core_colors';
20
21
 
21
22
  import { BoxProps, HBox } from '../boxes/boxes';
22
23
  import { Input } from '../input/input';
23
24
  import { Button } from '../button/button';
25
+ import { Popup } from '../popup/popup';
26
+ import { ColorPicker, EvColorChange } from '../colorpicker/colorpicker';
24
27
 
25
28
 
26
29
  import "./colorinput.module.scss"
27
30
  import icon from "./crosshairs-simple-sharp-light.svg"
28
31
 
29
- //TODO: add swatches
30
32
  //TODO: better keyboard handling (selection after cursor)
31
33
 
34
+ type Swatches = ( Color | string )[];
35
+
32
36
  /**
33
- *
37
+ *
34
38
  */
35
39
 
36
- interface ColorInputProps extends BoxProps {
40
+ export interface ColorInputProps extends BoxProps {
41
+ /** The color at start. null: no color is shown. */
37
42
  color: Color | string;
43
+
44
+ /** Name of the field: with it, a Form reads and writes the color (as "#rrggbb"). */
45
+ name?: string;
46
+
47
+ /** How the field writes the color. "rgb" by default: rgb(r,g,b). "hex": #rrggbb. */
48
+ format?: "rgb" | "hex";
49
+
50
+ /** A click on the swatch opens a color picker. */
51
+ picker?: boolean;
52
+
53
+ /**
54
+ * Colors offered in the picker. A function is called each time the
55
+ * picker opens: the list may change with time.
56
+ */
57
+ swatches?: Swatches | ( ( ) => Swatches );
58
+
59
+ /** The field may have no color: emptied, or "no color" picked. */
60
+ nullable?: boolean;
61
+
62
+ /** The user changed the color (not fired by setColor). */
63
+ change?: EventCallback<EvColorChange>;
64
+ }
65
+
66
+ interface ColorInputEvents extends ComponentEvents {
67
+ change: EvColorChange;
38
68
  }
39
69
 
40
70
  /**
@@ -43,56 +73,199 @@ interface ColorInputProps extends BoxProps {
43
73
  * --colorinput-btn-background
44
74
  * --colorinput-btn-color
45
75
  * --colorinput-btn-color-hover
76
+ * --colorinput-popup-background
46
77
  * ```
47
78
  */
48
79
 
49
80
  @class_ns( "x4" )
50
- export class ColorInput extends HBox<ColorInputProps> {
81
+ export class ColorInput extends HBox<ColorInputProps,ColorInputEvents> {
51
82
  static "$cls-ns" = "x4";
52
83
 
84
+ private _color: Color; // null: no color
85
+ private _swatch: Component;
86
+ private _edit: Input;
87
+ private _swatches: Swatches | ( ( ) => Swatches );
88
+ private _popup: Popup; // the color picker, once it was opened
89
+ private _closedAt = -1; // when it closed for the last time (performance.now)
90
+
53
91
  constructor( props: ColorInputProps ) {
54
92
  super( props );
55
93
 
56
- let swatch: Component;
57
- let edit: Input;
94
+ this.mapPropEvents( props, "change" );
95
+ this._swatches = props.swatches;
96
+
97
+ if( props.name ) {
98
+ this.setAttribute( "name", props.name );
99
+ }
58
100
 
59
101
  this.setContent( [
60
- swatch = new Component( { cls: "swatch" } ),
61
- edit = new Input( { type: "text", value: "", spellcheck: false } ),
102
+ this._swatch = new Component( { cls: "swatch" } ),
103
+ this._edit = new Input( { type: "text", value: "", spellcheck: false } ),
62
104
 
63
105
  isFeatureAvailable("eyedropper") ? new Button( { icon: icon, click: ( ) => {
64
106
  const eyeDropper = new (window as any).EyeDropper();
65
107
  eyeDropper.open( ).then( ( result: any ) => {
66
- color = new Color( result.sRGBHex );
67
- updateColor( color );
108
+ this._changed( new Color( result.sRGBHex ) );
68
109
  }).catch( (_: any ) => {
69
110
  /* silence */
70
111
  });
71
112
  } } ) : null
72
113
  ])
73
114
 
74
- edit.addDOMEvent( "input", ( ) => {
75
- const txt = edit.getValue( );
115
+ this._edit.addDOMEvent( "input", ( ) => {
116
+ const txt = this._edit.getValue( );
117
+
118
+ if( this.props.nullable && txt.trim( )=="" ) {
119
+ this._changed( null, false );
120
+ return;
121
+ }
122
+
76
123
  const clr = new Color( txt );
77
124
  if( !clr.isInvalid() ) {
78
- color = clr;
79
- updateColor( color );
125
+ this._changed( clr );
80
126
  }
81
127
  });
82
128
 
83
- const updateColor = ( clr: Color ) => {
84
- swatch.setStyleValue( "backgroundColor", clr.toRgbString(false) );
85
- edit.setValue( clr.toRgbString(false) );
129
+ if( props.picker ) {
130
+ // A click on the swatch opens the picker, a second one closes it.
131
+ // The picker closes by itself as soon as the button goes down
132
+ // outside of it, before the swatch hears of it: it was open if
133
+ // it closed since this very press began.
134
+ let wasOpen = false;
135
+
136
+ this._swatch.addClass( "picker" );
137
+ this._swatch.addDOMEvent( "pointerdown", ev => { wasOpen = this._closedAt >= ev.timeStamp; } );
138
+ this._swatch.addDOMEvent( "click", ( ) => {
139
+ if( !wasOpen ) {
140
+ this._openPicker( );
141
+ }
142
+ } );
143
+ }
144
+
145
+ this._show( this._parse( props.color ) );
146
+ }
147
+
148
+ private _parse( color: Color | string ): Color {
149
+ if( color instanceof Color ) {
150
+ return color;
86
151
  }
87
152
 
88
- let color: Color;
89
- if( props.color instanceof Color ) {
90
- color = props.color;
153
+ // Nothing to show (a color that is not known, or several ones). Whether
154
+ // the user may choose "no color" is another matter: props.nullable.
155
+ if( color===null || color===undefined || color==="" ) {
156
+ return null;
157
+ }
158
+
159
+ return new Color( color );
160
+ }
161
+
162
+ private _format( color: Color ): string {
163
+ return this.props.format=="hex" ? color.toHexString( ) : color.toRgbString( false );
164
+ }
165
+
166
+ /**
167
+ * show a color: swatch and text
168
+ * @param text false while the user empties the field: the text is left alone
169
+ */
170
+
171
+ private _show( color: Color, text = true ) {
172
+ this._color = color;
173
+
174
+ this._swatch.setClass( "none", !color );
175
+ this._swatch.setStyleValue( "backgroundColor", color ? color.toRgbString(false) : null );
176
+
177
+ if( text ) {
178
+ this._edit.setValue( color ? this._format( color ) : "" );
91
179
  }
92
- else {
93
- color = new Color( props.color );
180
+ }
181
+
182
+ /**
183
+ * the user changed the color
184
+ */
185
+
186
+ private _changed( color: Color, text = true ) {
187
+ this._show( color, text );
188
+ this.fire( "change", { color, value: color ? color.toHexString( ) : null } );
189
+ }
190
+
191
+ /**
192
+ * The color picker, with the swatches, under the field. It closes on
193
+ * a click outside of it.
194
+ */
195
+
196
+ private _openPicker( ) {
197
+ const swatches = isFunction( this._swatches ) ? this._swatches( ) : this._swatches;
198
+
199
+ // the picker works on its own color: it changes it in place
200
+ const picker = new ColorPicker( {
201
+ color: this._color ? new Color( this._color.toHexString( ) ) : new Color( 255, 255, 255 ),
202
+ swatches,
203
+ nullable: this.props.nullable,
204
+ } );
205
+
206
+ const popup = this._popup = new Popup( { cls: "x4colorinput-popup", autoClose: true, content: picker } );
207
+ popup.on( "closed", ( ) => { this._closedAt = performance.now( ); } );
208
+
209
+ // after the construction: the picker fires a first change when it is built
210
+ picker.on( "change", ev => {
211
+ this._changed( ev.color ? new Color( ev.value ) : null );
212
+
213
+ if( !ev.color ) {
214
+ popup.close( );
215
+ }
216
+ } );
217
+
218
+ popup.displayNear( this.getBoundingRect( ), "top left", "bottom left", { x: 0, y: 6 } );
219
+ }
220
+
221
+ /**
222
+ * The current color, null when there is none.
223
+ */
224
+
225
+ getColor( ): Color {
226
+ return this._color;
227
+ }
228
+
229
+ /**
230
+ * The current color as "#rrggbb" ("#rrggbbaa" when it is not opaque),
231
+ * null when there is none.
232
+ */
233
+
234
+ getValue( ): string {
235
+ return this._color ? this._color.toHexString( ) : null;
236
+ }
237
+
238
+ /**
239
+ * Shows another color, or none (null). Does not fire `change`.
240
+ */
241
+
242
+ setColor( color: Color | string ) {
243
+ this._show( this._parse( color ) );
244
+ }
245
+
246
+ /**
247
+ * Changes the colors offered in the picker (see props.swatches).
248
+ */
249
+
250
+ setSwatches( swatches: Swatches | ( ( ) => Swatches ) ) {
251
+ this._swatches = swatches;
252
+ }
253
+
254
+ /**
255
+ *
256
+ */
257
+
258
+ override queryInterface<T extends IComponentInterface>( name: string ): T {
259
+ if( name=="form-element" ) {
260
+ const i: IFormElement = {
261
+ getRawValue: ( ): any => { return this.getValue( ); },
262
+ setRawValue: ( v: any ) => { this.setColor( v ); },
263
+ isValid: ( ) => { return true; }
264
+ };
265
+
266
+ return i as unknown as T;
94
267
  }
95
268
 
96
- updateColor( color );
269
+ return super.queryInterface( name );
97
270
  }
98
- }
271
+ }
@@ -130,4 +130,27 @@
130
130
  }
131
131
  }
132
132
  }
133
+
134
+ // the colors offered under the sliders (props.swatches)
135
+ .swatches {
136
+ flex-wrap: wrap;
137
+ gap: 4px;
138
+
139
+ .item {
140
+ width: 18px;
141
+ height: 18px;
142
+ border: 1px solid var( --border );
143
+ border-radius: 3px;
144
+ cursor: pointer;
145
+
146
+ &:hover {
147
+ border-color: var( --border-focus );
148
+ }
149
+
150
+ // no color: crossed out
151
+ &.none {
152
+ background: linear-gradient( to top right, transparent 45%, #d04040 45%, #d04040 55%, transparent 55% );
153
+ }
154
+ }
155
+ }
133
156
  }