x4js 2.3.13 → 2.3.14

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
@@ -130,7 +130,7 @@ interface ComponentProps {
130
130
  ns?: string // SVG/MathML namespace
131
131
  cls?: string // extra CSS classes
132
132
  id?: string
133
- style?: Partial<CSSStyleDeclaration>
133
+ style?: StyleProps // numbers get "px" except unitless
134
134
  attrs?: Record<string, string | number | boolean>
135
135
  content?: ComponentContent
136
136
  dom_events?: GlobalDOMEvents
@@ -188,7 +188,7 @@ getInternalData<T>(name: string|symbol): T
188
188
  ### Styles
189
189
 
190
190
  ```ts
191
- setStyle(style: Partial<CSSStyleDeclaration>): this // numbers auto-suffixed "px" except unitless
191
+ setStyle(style: StyleProps): this // numbers auto-suffixed "px" except unitless
192
192
  setStyleValue<K>(name: K, value): this
193
193
  getStyleValue<K>(name: K): CSSStyleDeclaration[K]
194
194
  setWidth(w: number|string): void
@@ -633,7 +633,20 @@ class AutoComplete extends TextEdit {
633
633
  ### `ColorInput`
634
634
 
635
635
  ```ts
636
- class ColorInput extends HBox {} // queryInterface("form-element")
636
+ interface ColorInputProps extends BoxProps {
637
+ color: Color | string // null: no color shown
638
+ name?: string
639
+ format?: "rgb" | "hex" // text of the field, "rgb" by default
640
+ picker?: boolean // a click on the swatch opens a ColorPicker
641
+ swatches?: (Color|string)[] | (() => (Color|string)[]) // a function is called each time the picker opens
642
+ nullable?: boolean // "no color" can be chosen
643
+ change?: EventCallback<EvColorChange> // user changes only, not setColor
644
+ }
645
+ class ColorInput extends HBox { // queryInterface("form-element"): "#rrggbb" or null
646
+ getColor(): Color; getValue(): string
647
+ setColor(color: Color | string): void; setSwatches(swatches): void
648
+ }
649
+ interface EvColorChange extends EvChange { color: Color; value: string } // "#rrggbb" or "#rrggbbaa"; both null for "no color"
637
650
  ```
638
651
 
639
652
  ### `FileDrop` / `FileDialog`
@@ -796,6 +809,9 @@ class PropertyGrid extends VBox {
796
809
  setItems(groups: PropertyGroup[]): void
797
810
  setPropValue(name: string, value): void
798
811
  }
812
+ // PropertyValue.type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'color' | 'label' | 'button'
813
+ // 'color': the editor is a ColorInput (hex, with picker); swatches?: string[] | (() => string[]), nullable?: boolean,
814
+ // callback(name, value) receives "#rrggbb", or null for "no color"
799
815
  ```
800
816
 
801
817
  ---
@@ -978,7 +994,9 @@ class Video extends Box {
978
994
  set stream(stream: MediaStream)
979
995
  }
980
996
  class Calendar extends VBox { getDate(): Date; setDate(date: Date): void }
981
- class ColorPicker extends VBox // queryInterface("form-element")
997
+ class ColorPicker extends VBox { // props: color, swatches?, nullable?; "change": EvColorChange, also fired once when built
998
+ getColor(): Color; setColor(color: Color | string): void; setSwatches(swatches): void
999
+ }
982
1000
  class Panel extends VBox { setTitle(title: string): void }
983
1001
  class Breadcrumbs extends HBox { setItems(elements): void }
984
1002
  class Keyboard extends HBox // virtual keyboard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.13",
3
+ "version": "2.3.14",
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,196 @@ 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
+
53
90
  constructor( props: ColorInputProps ) {
54
91
  super( props );
55
92
 
56
- let swatch: Component;
57
- let edit: Input;
93
+ this.mapPropEvents( props, "change" );
94
+ this._swatches = props.swatches;
95
+
96
+ if( props.name ) {
97
+ this.setAttribute( "name", props.name );
98
+ }
58
99
 
59
100
  this.setContent( [
60
- swatch = new Component( { cls: "swatch" } ),
61
- edit = new Input( { type: "text", value: "", spellcheck: false } ),
101
+ this._swatch = new Component( { cls: "swatch" } ),
102
+ this._edit = new Input( { type: "text", value: "", spellcheck: false } ),
62
103
 
63
104
  isFeatureAvailable("eyedropper") ? new Button( { icon: icon, click: ( ) => {
64
105
  const eyeDropper = new (window as any).EyeDropper();
65
106
  eyeDropper.open( ).then( ( result: any ) => {
66
- color = new Color( result.sRGBHex );
67
- updateColor( color );
107
+ this._changed( new Color( result.sRGBHex ) );
68
108
  }).catch( (_: any ) => {
69
109
  /* silence */
70
110
  });
71
111
  } } ) : null
72
112
  ])
73
113
 
74
- edit.addDOMEvent( "input", ( ) => {
75
- const txt = edit.getValue( );
114
+ this._edit.addDOMEvent( "input", ( ) => {
115
+ const txt = this._edit.getValue( );
116
+
117
+ if( this.props.nullable && txt.trim( )=="" ) {
118
+ this._changed( null, false );
119
+ return;
120
+ }
121
+
76
122
  const clr = new Color( txt );
77
123
  if( !clr.isInvalid() ) {
78
- color = clr;
79
- updateColor( color );
124
+ this._changed( clr );
80
125
  }
81
126
  });
82
127
 
83
- const updateColor = ( clr: Color ) => {
84
- swatch.setStyleValue( "backgroundColor", clr.toRgbString(false) );
85
- edit.setValue( clr.toRgbString(false) );
128
+ if( props.picker ) {
129
+ // A click on the swatch opens the picker, a second one closes it.
130
+ // The picker closes by itself as soon as the button goes down
131
+ // outside of it: whether it was open is to be known before that.
132
+ let wasOpen = false;
133
+
134
+ this._swatch.addClass( "picker" );
135
+ this._swatch.addDOMEvent( "pointerdown", ( ) => { wasOpen = !!this._popup?.isOpen( ); } );
136
+ this._swatch.addDOMEvent( "click", ( ) => {
137
+ if( !wasOpen ) {
138
+ this._openPicker( );
139
+ }
140
+ } );
141
+ }
142
+
143
+ this._show( this._parse( props.color ) );
144
+ }
145
+
146
+ private _parse( color: Color | string ): Color {
147
+ if( color instanceof Color ) {
148
+ return color;
86
149
  }
87
150
 
88
- let color: Color;
89
- if( props.color instanceof Color ) {
90
- color = props.color;
151
+ // Nothing to show (a color that is not known, or several ones). Whether
152
+ // the user may choose "no color" is another matter: props.nullable.
153
+ if( color===null || color===undefined || color==="" ) {
154
+ return null;
155
+ }
156
+
157
+ return new Color( color );
158
+ }
159
+
160
+ private _format( color: Color ): string {
161
+ return this.props.format=="hex" ? color.toHexString( ) : color.toRgbString( false );
162
+ }
163
+
164
+ /**
165
+ * show a color: swatch and text
166
+ * @param text false while the user empties the field: the text is left alone
167
+ */
168
+
169
+ private _show( color: Color, text = true ) {
170
+ this._color = color;
171
+
172
+ this._swatch.setClass( "none", !color );
173
+ this._swatch.setStyleValue( "backgroundColor", color ? color.toRgbString(false) : null );
174
+
175
+ if( text ) {
176
+ this._edit.setValue( color ? this._format( color ) : "" );
91
177
  }
92
- else {
93
- color = new Color( props.color );
178
+ }
179
+
180
+ /**
181
+ * the user changed the color
182
+ */
183
+
184
+ private _changed( color: Color, text = true ) {
185
+ this._show( color, text );
186
+ this.fire( "change", { color, value: color ? color.toHexString( ) : null } );
187
+ }
188
+
189
+ /**
190
+ * The color picker, with the swatches, under the field. It closes on
191
+ * a click outside of it.
192
+ */
193
+
194
+ private _openPicker( ) {
195
+ const swatches = isFunction( this._swatches ) ? this._swatches( ) : this._swatches;
196
+
197
+ // the picker works on its own color: it changes it in place
198
+ const picker = new ColorPicker( {
199
+ color: this._color ? new Color( this._color.toHexString( ) ) : new Color( 255, 255, 255 ),
200
+ swatches,
201
+ nullable: this.props.nullable,
202
+ } );
203
+
204
+ const popup = this._popup = new Popup( { cls: "x4colorinput-popup", autoClose: true, content: picker } );
205
+
206
+ // after the construction: the picker fires a first change when it is built
207
+ picker.on( "change", ev => {
208
+ this._changed( ev.color ? new Color( ev.value ) : null );
209
+
210
+ if( !ev.color ) {
211
+ popup.close( );
212
+ }
213
+ } );
214
+
215
+ popup.displayNear( this.getBoundingRect( ), "top left", "bottom left", { x: 0, y: 6 } );
216
+ }
217
+
218
+ /**
219
+ * The current color, null when there is none.
220
+ */
221
+
222
+ getColor( ): Color {
223
+ return this._color;
224
+ }
225
+
226
+ /**
227
+ * The current color as "#rrggbb" ("#rrggbbaa" when it is not opaque),
228
+ * null when there is none.
229
+ */
230
+
231
+ getValue( ): string {
232
+ return this._color ? this._color.toHexString( ) : null;
233
+ }
234
+
235
+ /**
236
+ * Shows another color, or none (null). Does not fire `change`.
237
+ */
238
+
239
+ setColor( color: Color | string ) {
240
+ this._show( this._parse( color ) );
241
+ }
242
+
243
+ /**
244
+ * Changes the colors offered in the picker (see props.swatches).
245
+ */
246
+
247
+ setSwatches( swatches: Swatches | ( ( ) => Swatches ) ) {
248
+ this._swatches = swatches;
249
+ }
250
+
251
+ /**
252
+ *
253
+ */
254
+
255
+ override queryInterface<T extends IComponentInterface>( name: string ): T {
256
+ if( name=="form-element" ) {
257
+ const i: IFormElement = {
258
+ getRawValue: ( ): any => { return this.getValue( ); },
259
+ setRawValue: ( v: any ) => { this.setColor( v ); },
260
+ isValid: ( ) => { return true; }
261
+ };
262
+
263
+ return i as unknown as T;
94
264
  }
95
265
 
96
- updateColor( color );
266
+ return super.queryInterface( name );
97
267
  }
98
- }
268
+ }
@@ -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
  }
@@ -17,13 +17,19 @@
17
17
  import { Color, Hsv } from '../../core/core_colors';
18
18
  import { Rect, clamp, class_ns, isFeatureAvailable } from '../../core/core_tools';
19
19
 
20
- import { Component, ComponentEvent, ComponentEvents, ComponentProps } from '../../core/component';
20
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvChange } from '../../core/component';
21
21
  import { Box, BoxProps, HBox, VBox } from '../boxes/boxes';
22
22
 
23
23
  import "./colorpicker.module.scss"
24
24
 
25
- interface ColorPickerProps extends ComponentProps {
25
+ export interface ColorPickerProps extends ComponentProps {
26
26
  color: string | Color;
27
+
28
+ /** Colors offered under the sliders, to be picked with a click. */
29
+ swatches?: ( string | Color )[];
30
+
31
+ /** Adds a "no color" swatch: picking it fires `change` with a null color. */
32
+ nullable?: boolean;
27
33
  }
28
34
 
29
35
  interface HueChangeEvent extends ComponentEvent {
@@ -127,6 +133,18 @@ export class Saturation extends Box<BoxProps,CommonEvents> {
127
133
  this.color.setStyleValue( "backgroundColor", base.toRgbString(false) );
128
134
  }
129
135
 
136
+ /**
137
+ * Shows another color: base color and thumb. Does not fire `sat_change`.
138
+ */
139
+
140
+ setColor( hsv: Hsv ) {
141
+ this.hsv.saturation = hsv.saturation;
142
+ this.hsv.value = hsv.value;
143
+
144
+ this.updateBaseColor( hsv );
145
+ this.updateThumbMarker( );
146
+ }
147
+
130
148
  move( sens: string, delta: number ) {
131
149
  switch( sens ) {
132
150
  case 'saturation': {
@@ -335,31 +353,41 @@ class AlphaSlider extends Box<BoxProps,CommonEvents> {
335
353
  *
336
354
  */
337
355
 
338
- interface ChangeEvent extends ComponentEvent {
356
+ /**
357
+ * Fired by ColorPicker and ColorInput when the user changes the color.
358
+ *
359
+ * `value` is the color as text: "#rrggbb", or "#rrggbbaa" when it is not
360
+ * opaque. Both are null when the user picked "no color" (`nullable`).
361
+ */
362
+
363
+ export interface EvColorChange extends EvChange {
339
364
  color: Color;
365
+ readonly value: string;
340
366
  }
341
367
 
342
368
  interface ColorPickerChangeEvents extends ComponentEvents {
343
- change: ChangeEvent
369
+ change: EvColorChange
344
370
  }
345
371
 
346
372
  /**
347
- *
373
+ *
348
374
  */
349
375
 
350
376
  @class_ns( "x4" )
351
377
  export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents> {
352
378
 
353
379
  private _base: Color;
380
+ private _hsv: Hsv;
354
381
  private _sat: Saturation;
355
382
  private _swatch: Component;
356
383
  private _hue: HueSlider;
357
384
  private _alpha: AlphaSlider;
385
+ private _swatches: HBox;
358
386
 
359
387
 
360
388
  constructor( props: ColorPickerProps ) {
361
389
  super( props );
362
-
390
+
363
391
  if( props.color instanceof Color ) {
364
392
  this._base = props.color;
365
393
  }
@@ -367,7 +395,7 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
367
395
  this._base = new Color( props.color );
368
396
  }
369
397
 
370
- let hsv = this._base.toHsv( );
398
+ const hsv = this._hsv = this._base.toHsv( );
371
399
 
372
400
  this.setAttribute( "tabindex", 0 );
373
401
 
@@ -385,48 +413,34 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
385
413
  this._swatch = new Component( { cls: "overlay" } ),
386
414
  ] } )
387
415
  ]
388
- })
416
+ }),
417
+ this._swatches = new HBox( { cls: "swatches" } ),
389
418
  ]);
390
419
 
391
420
  this._sat.on( "sat_change", ( ev ) => {
392
- hsv.saturation = ev.saturation;
393
- hsv.value = ev.value;
394
- updateColor( );
395
- this._alpha.updateBaseColor( hsv );
421
+ this._hsv.saturation = ev.saturation;
422
+ this._hsv.value = ev.value;
423
+ this._changed( );
424
+ this._alpha.updateBaseColor( this._hsv );
396
425
  } );
397
426
 
398
427
  this._hue.on( 'hue_change', ( ev ) => {
399
- hsv.hue = ev.hue;
400
- this._sat.updateBaseColor( hsv );
401
- this._alpha.updateBaseColor( hsv );
402
- updateColor( );
428
+ this._hsv.hue = ev.hue;
429
+ this._sat.updateBaseColor( this._hsv );
430
+ this._alpha.updateBaseColor( this._hsv );
431
+ this._changed( );
403
432
  } );
404
433
 
405
434
  this._alpha.on( 'alpha_change', ( ev ) => {
406
- hsv.alpha = ev.alpha;
407
- updateColor( );
435
+ this._hsv.alpha = ev.alpha;
436
+ this._changed( );
408
437
  } );
409
438
 
410
- const updateColor = ( ) => {
411
- this._base.setHsv( hsv.hue, hsv.saturation, hsv.value, hsv.alpha );
412
- this._swatch.setStyleValue( "backgroundColor", this._base.toRgbString() );
413
- this._swatch.setAttribute( "tooltip", this._base.toRgbString() );
414
-
415
- this.fire( "change", { color: this._base } );
416
- }
417
-
418
439
  if( isFeatureAvailable("eyedropper") ) {
419
440
  this._swatch.addDOMEvent( "click", ( e ) => {
420
441
  const eyeDropper = new (window as any).EyeDropper();
421
442
  eyeDropper.open( ).then( ( result: any ) => {
422
- const color = new Color( result.sRGBHex );
423
- hsv = color.toHsv( );
424
-
425
- this._alpha.setColor( hsv );
426
-
427
- this._sat.updateBaseColor( hsv );
428
- this._hue.updateHue( hsv );
429
- updateColor( );
443
+ this._pick( new Color( result.sRGBHex ) );
430
444
  }).catch( (_: any )=> {
431
445
  /* silence */
432
446
  } );
@@ -435,7 +449,107 @@ export class ColorPicker extends VBox<ColorPickerProps,ColorPickerChangeEvents>
435
449
 
436
450
  this.addDOMEvent( "keydown", ( ev ) => this._onkey( ev ) );
437
451
 
438
- updateColor( );
452
+ this.setSwatches( props.swatches );
453
+ this._changed( );
454
+ }
455
+
456
+ /**
457
+ * the sliders moved: compute the color, show it, tell it
458
+ */
459
+
460
+ private _changed( ) {
461
+ const hsv = this._hsv;
462
+
463
+ this._base.setHsv( hsv.hue, hsv.saturation, hsv.value, hsv.alpha );
464
+ this._refresh( );
465
+ this._notify( );
466
+ }
467
+
468
+ private _refresh( ) {
469
+ this._swatch.setStyleValue( "backgroundColor", this._base.toRgbString() );
470
+ this._swatch.setAttribute( "tooltip", this._base.toRgbString() );
471
+ }
472
+
473
+ private _notify( ) {
474
+ this.fire( "change", { color: this._base, value: this._base.toHexString() } );
475
+ }
476
+
477
+ /**
478
+ * the user took a color as a whole (eye dropper, swatch)
479
+ */
480
+
481
+ private _pick( color: Color ) {
482
+ this.setColor( color );
483
+ this._notify( );
484
+ }
485
+
486
+ /**
487
+ * The current color. It is the object the `change` event gives, and it
488
+ * changes with the picker: copy it to keep a value.
489
+ */
490
+
491
+ getColor( ): Color {
492
+ return this._base;
493
+ }
494
+
495
+ /**
496
+ * Shows another color. Does not fire `change`.
497
+ */
498
+
499
+ setColor( color: Color | string ) {
500
+ const rgb = ( color instanceof Color ? color : new Color( color ) ).toRgb( );
501
+
502
+ // the color itself is kept as given: going through hsv could shift it
503
+ this._base.setRgb( rgb.red, rgb.green, rgb.blue, rgb.alpha );
504
+
505
+ const hsv = this._base.toHsv( );
506
+ this._hsv.hue = hsv.hue;
507
+ this._hsv.saturation = hsv.saturation;
508
+ this._hsv.value = hsv.value;
509
+ this._hsv.alpha = hsv.alpha;
510
+
511
+ this._sat.setColor( this._hsv );
512
+ this._hue.updateHue( this._hsv );
513
+ this._alpha.setColor( this._hsv );
514
+
515
+ this._refresh( );
516
+ }
517
+
518
+ /**
519
+ * Colors offered under the sliders. A click on one of them takes it
520
+ * and fires `change`. Nothing is shown without swatches.
521
+ */
522
+
523
+ setSwatches( swatches: ( string | Color )[] ) {
524
+ const items: Component[] = [];
525
+
526
+ if( this.props.nullable ) {
527
+ items.push( new Component( {
528
+ cls: "item none",
529
+ dom_events: {
530
+ click: ( ) => this.fire( "change", { color: null, value: null } ),
531
+ },
532
+ } ) );
533
+ }
534
+
535
+ for( const swatch of swatches ?? [] ) {
536
+ const color = swatch instanceof Color ? swatch : new Color( swatch );
537
+ if( color.isInvalid( ) ) {
538
+ continue;
539
+ }
540
+
541
+ items.push( new Component( {
542
+ cls: "item",
543
+ tooltip: color.toHexString( ),
544
+ style: { backgroundColor: color.toRgbString( ) },
545
+ dom_events: {
546
+ click: ( ) => this._pick( color ),
547
+ },
548
+ } ) );
549
+ }
550
+
551
+ this._swatches.setContent( items );
552
+ this._swatches.show( items.length>0 );
439
553
  }
440
554
 
441
555
  private _onkey( ev: KeyboardEvent ) {
@@ -12,8 +12,12 @@
12
12
  --propertygrid-odd-background: var(--color-primary-a5);
13
13
  --propertygrid-even-background: transparent;
14
14
  --propertygrid-disabled-background: rgb(170 170 170 / 14%);
15
+ --propertygrid-hover-background: var(--color-primary-a10);
16
+ --propertygrid-focus-background: var(--color-primary-a20);
17
+ --propertygrid-input-background: rgba(255,255,255,0.6);
15
18
 
16
19
  --propertygrid-group-background: var( --color-primary-a5 );
20
+ --propertygrid-group-color: var( --color-primary-a50 );
17
21
  }
18
22
 
19
23
  .x4propertygrid {
@@ -36,19 +40,23 @@
36
40
  margin: 0;
37
41
  }
38
42
 
43
+ // Two columns shared by all the rows: the titles, as wide as the longest
44
+ // of them but never more than half of the grid, and the editors, which
45
+ // take the rest.
39
46
  .root {
40
47
  display: grid;
41
- grid-template-columns: 1fr;
48
+ grid-template-columns: fit-content( 50% ) 1fr;
42
49
  grid-template-rows: auto;
43
50
  }
44
51
 
45
52
  .group {
53
+ grid-column: 1 / -1;
46
54
  padding: 6px 4px 4px 4px;
47
55
  min-height: 2em;
48
56
  background-color: var( --propertygrid-group-background );
49
57
  margin-bottom: 8px;
50
58
  box-shadow: 4px 3px 8px rgba(0, 0, 0, 0.4);
51
- color: var(--color-primary-a50);
59
+ color: var( --propertygrid-group-color );
52
60
  display: flex;
53
61
  align-items: center;
54
62
  padding: 8px;
@@ -75,14 +83,14 @@
75
83
 
76
84
  &>.x4button {
77
85
  background-color: transparent;
78
- color: var(--color-primary-a50);
86
+ color: var( --propertygrid-group-color );
79
87
  }
80
-
88
+
81
89
  &>.x4label {
82
90
  .x4icon {
83
91
  .fa-secondary,
84
92
  .fa-primary {
85
- fill: var( --color-primary-a50 );
93
+ fill: var( --propertygrid-group-color );
86
94
  }
87
95
 
88
96
  width: 1em;
@@ -103,52 +111,62 @@
103
111
  }
104
112
 
105
113
  &:not( :last-child ) {
106
- border-bottom: 1px solid var(--color-primary-a50);
114
+ border-bottom: 1px solid var( --propertygrid-group-color );
107
115
  //box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.2);
108
116
  }
109
117
  }
110
118
 
119
+ // A row has no box of its own: its two cells go straight on the columns
120
+ // of the grid, which is how all the titles get the same width. (A row is
121
+ // a fieldset: it cannot be a subgrid.) What a row looked like, background
122
+ // and bottom line, is carried by its cells.
111
123
  .row {
112
- border-bottom: 1px solid var( --border-light );
113
- //border-top: none;
114
- //border-left: 1px solid var(--color-primary-a50);
115
- //border-right: 1px solid var(--color-primary-a50);
124
+ display: contents;
125
+
126
+ > .cell {
127
+ min-width: 0;
128
+ padding: 4px;
129
+ border-bottom: 1px solid var( --border-light );
130
+
131
+ input {
132
+ background: var( --propertygrid-input-background );
133
+ }
134
+ }
116
135
 
117
- &.even:not(:disabled) {
136
+ &.even:not(:disabled) > .cell {
118
137
  background-color: var(--propertygrid-even-background)
119
138
  }
120
-
121
- &.odd:not(:disabled) {
139
+
140
+ &.odd:not(:disabled) > .cell {
122
141
  background-color: var(--propertygrid-odd-background)
123
142
  }
124
-
125
- &.odd:disabled {
143
+
144
+ &.odd:disabled > .cell {
126
145
  background-color: var(--propertygrid-disabled-background)
127
146
  }
128
-
129
- &:has(:focus) {
130
- background-color: var(--color-primary-a20);
131
- }
132
147
 
133
- &:hover {
134
- background-color: var(--color-primary-a10);
148
+ &:has(:focus) > .cell {
149
+ background-color: var( --propertygrid-focus-background );
135
150
  }
136
-
137
- .cell {
138
- flex-grow: 1;
139
- flex-basis: 10px;
140
- padding: 4px;
141
151
 
142
- input {
143
- background: rgba(255,255,255,0.6);
144
- }
152
+ &:hover > .cell {
153
+ background-color: var( --propertygrid-hover-background );
145
154
  }
146
155
 
147
- .cell:first-child {
156
+ > .cell:first-child {
148
157
  border-right: 1px solid var( --border );
149
158
  }
150
159
 
151
- .hdr {
160
+ // a row without title (a button): its editor takes the two columns
161
+ > .cell:only-child {
162
+ grid-column: 1 / -1;
163
+ }
164
+
165
+ // The title, centered in the height of the row. Wider than half of
166
+ // the grid, it is cut by the column and goes on several lines.
167
+ > .hdr {
168
+ display: flex;
169
+ align-items: center;
152
170
  padding: 8px;
153
171
  padding-left: 34px;
154
172
  }
@@ -23,6 +23,8 @@ import { ListboxID, ListItem } from "../listbox/listbox"
23
23
  import { Label, SimpleText } from "../label/label"
24
24
  import { asap, class_ns, isFunction, isNumber } from '../../core/core_tools';
25
25
  import { Icon, ScrollView } from '../components'
26
+ import { ColorInput } from '../colorinput/colorinput'
27
+ import { EvColorChange } from '../colorpicker/colorpicker'
26
28
 
27
29
  import icons from "../assets/icons"
28
30
 
@@ -32,12 +34,14 @@ type IValue = boolean | number | string;
32
34
  type IValueCB = ( name: string) => IValue;
33
35
 
34
36
  export interface PropertyValue {
35
- type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'label' | 'button';
37
+ type: 'boolean' | 'number' | 'string' | 'password' | 'options' | 'color' | 'label' | 'button';
36
38
  title?: string;
37
39
  desc?: string;
38
40
  name?: string;
39
41
  value: IValue | IValueCB;
40
42
  options?: ListItem[];
43
+ swatches?: string[] | ( ( ) => string[] ); // for colors: offered in the picker; a function is called when it opens
44
+ nullable?: boolean; // for colors: "no color" can be chosen, the value is then null
41
45
  callback?: ( name: string, value: any ) => void;
42
46
  live?: boolean; // for live update
43
47
  cls?: string;
@@ -67,6 +71,16 @@ export interface PropertyProps extends ComponentProps {
67
71
  * @cssvar
68
72
  * ```
69
73
  * --propertygrid-background
74
+ * --propertygrid-odd-background
75
+ * --propertygrid-even-background
76
+ * --propertygrid-disabled-background
77
+ * --propertygrid-hover-background
78
+ * --propertygrid-focus-background
79
+ * --propertygrid-input-background
80
+ * --propertygrid-group-background
81
+ * --propertygrid-group-color
82
+ * --propertygrid-gadget-background
83
+ * --propertygrid-gadget-color
70
84
  * ```
71
85
  */
72
86
 
@@ -260,10 +274,23 @@ export class PropertyGrid extends VBox {
260
274
  }
261
275
  });
262
276
  }
277
+ else if (item.type === 'color') {
278
+ editor = new ColorInput({
279
+ id: item.name,
280
+ color: value as string,
281
+ format: "hex",
282
+ picker: true,
283
+ swatches: item.swatches,
284
+ nullable: item.nullable,
285
+ change: ( e: EvColorChange ) => {
286
+ item.callback?.( item.name, e.value );
287
+ }
288
+ });
289
+ }
263
290
  else if (item.type === 'label') {
264
- editor = new Label({
291
+ editor = new Label({
265
292
  id: item.name,
266
- text: value as string,
293
+ text: value as string,
267
294
  });
268
295
  }
269
296
  else if (item.type === 'button') {
@@ -347,6 +374,12 @@ export class PropertyGrid extends VBox {
347
374
  editor.setNumValue( value as number, -2 );
348
375
  }
349
376
  }
377
+ else if (item.type === 'color') {
378
+ const editor = this.root.query<ColorInput>( '#'+item.name );
379
+ if( editor ) {
380
+ editor.setColor( value as string );
381
+ }
382
+ }
350
383
  else if (item.type === 'label') {
351
384
  const label = this.root.query<Label>( '#'+item.name );
352
385
  if( label ) {
@@ -70,6 +70,15 @@ export const makeUniqueComponentId = ( ) => {
70
70
  return `x4-${gen_id++}`;
71
71
  }
72
72
 
73
+ /**
74
+ * CSS properties. A number gets "px", except for the unitless properties
75
+ * (opacity, lineHeight, flexGrow...).
76
+ */
77
+
78
+ export type StyleProps = {
79
+ [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] | number;
80
+ };
81
+
73
82
  /**
74
83
  * Base properties for all components.
75
84
  */
@@ -80,7 +89,7 @@ export interface ComponentProps {
80
89
  /** Namespace for SVG/MathML elements. */
81
90
  ns?: string;
82
91
  /** Inline CSS styles. */
83
- style?: Partial<CSSStyleDeclaration>;
92
+ style?: StyleProps;
84
93
  /** HTML attributes. */
85
94
  attrs?: Record<string,string|number|boolean>;
86
95
  /** Child content (components, strings, or HTML). */
@@ -685,17 +694,17 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
685
694
  * @returns The component instance for chaining.
686
695
  */
687
696
 
688
- setStyle( style: Partial<CSSStyleDeclaration> ): this {
697
+ setStyle( style: StyleProps ): this {
689
698
  const _style = (this.dom as HTMLElement).style;
690
699
 
691
700
  for( const name in style ) {
692
-
693
- let value = style[name];
701
+
702
+ let value: any = style[name];
694
703
  if( !unitless[name] && (isNumber(value) || RE_NUMBER.test(value)) ) {
695
704
  value += "px";
696
705
  }
697
706
 
698
- _style[name] = value;
707
+ (_style as any)[name] = value;
699
708
  }
700
709
 
701
710
  return this;
@@ -264,7 +264,7 @@ export class Rect implements IRect {
264
264
 
265
265
  moveTo( x: number, y: number ) {
266
266
  this.left = x;
267
- this.top = x;
267
+ this.top = y;
268
268
 
269
269
  return this;
270
270
  }