x4js 2.3.26 → 2.3.28
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/LICENSE +21 -0
- package/README.md +5 -0
- package/aicontext.md +73 -6
- package/package.json +2 -1
- package/src/components/autocomplete/autocomplete.ts +6 -2
- package/src/components/boxes/boxes.ts +2 -2
- package/src/components/button/button.module.scss +4 -3
- package/src/components/calendar/calendar.ts +1 -1
- package/src/components/chart/chart.ts +1 -1
- package/src/components/checkbox/checkbox.module.scss +67 -1
- package/src/components/checkbox/checkbox.ts +22 -6
- package/src/components/colorinput/colorinput.module.scss +1 -0
- package/src/components/combobox/combobox.module.scss +4 -1
- package/src/components/components.ts +1 -0
- package/src/components/dockingview/dockingview.ts +1 -1
- package/src/components/gridview/gridview.module.scss +6 -0
- package/src/components/input/input.module.scss +1 -1
- package/src/components/listbox/listbox.ts +31 -45
- package/src/components/messages/messages.module.scss +9 -4
- package/src/components/monaco/monaco.ts +1 -2
- package/src/components/popup/popup.ts +2 -2
- package/src/components/propgrid/progrid.module.scss +13 -5
- package/src/components/radio/radio.module.scss +54 -31
- package/src/components/radio/radio.ts +12 -1
- package/src/components/rating/rating.ts +1 -1
- package/src/components/sizers/sizer.ts +17 -5
- package/src/components/spreadsheet/spreadsheet.module.scss +6 -0
- package/src/components/tabs/tabs.module.scss +2 -0
- package/src/components/tabs/tabs.ts +1 -0
- package/src/components/textarea/textarea.module.scss +9 -0
- package/src/components/textarea/textarea.ts +1 -0
- package/src/components/themes.scss +1 -0
- package/src/components/timeline/timeline.module.scss +147 -0
- package/src/components/timeline/timeline.ts +146 -0
- package/src/components/treeview/treeview.module.scss +6 -1
- package/src/components/treeview/treeview.ts +17 -22
- package/src/components/video/video.module.scss +5 -2
- package/src/components/video/video.ts +14 -1
- package/src/core/component.ts +31 -2
- package/src/core/core_pdf.ts +17 -0
- package/src/core/core_react.ts +12 -18
- package/src/core/core_shortcuts.ts +2 -1
- package/src/core/core_svg.ts +9 -6
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { class_ns } from '../../core/core_tools';
|
|
2
1
|
import { Component, ComponentProps } from '../../core/component';
|
|
3
2
|
|
|
4
3
|
import Monaco from './bin/monaco';
|
|
@@ -17,8 +16,8 @@ interface MonacoEditorProps extends ComponentProps {
|
|
|
17
16
|
options?: Monaco.editor.IEditorOptions & Monaco.editor.IGlobalEditorOptions;
|
|
18
17
|
}
|
|
19
18
|
|
|
20
|
-
@class_ns( "x4" )
|
|
21
19
|
export class MonacoEditor extends Component<MonacoEditorProps> {
|
|
20
|
+
static classPrefix = "x4";
|
|
22
21
|
|
|
23
22
|
private static _initCount = 0;
|
|
24
23
|
private static _basePath: string = "monaco/";
|
|
@@ -54,7 +54,7 @@ function getRoot( ) {
|
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
56
|
function dismissLast( e: UIEvent ) {
|
|
57
|
-
if( autoclose_list.some( x => x.
|
|
57
|
+
if( autoclose_list.some( x => x.contains( e.target as Node ) ) ) {
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
@@ -224,7 +224,7 @@ export class Popup<P extends PopupProps = PopupProps, E extends PopupEvents = Po
|
|
|
224
224
|
// modal: the focus goes back to the element that had it when it opened, unless the
|
|
225
225
|
// user put it elsewhere (read before the popup leaves the page and its focus with it)
|
|
226
226
|
const active = document.activeElement;
|
|
227
|
-
const giveBack = this._ismodal && this._prevFocus && ( !active || active===document.body || this.
|
|
227
|
+
const giveBack = this._ismodal && this._prevFocus && ( !active || active===document.body || this.contains( active ) );
|
|
228
228
|
|
|
229
229
|
this.__hide( );
|
|
230
230
|
this.__remove( );
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
--propertygrid-gadget-background: var( --background-primary );
|
|
10
10
|
--propertygrid-gadget-color: var( --text-primary );
|
|
11
11
|
|
|
12
|
-
--propertygrid-odd-background: var(--
|
|
12
|
+
--propertygrid-odd-background: var( --background-odd );
|
|
13
13
|
--propertygrid-even-background: transparent;
|
|
14
14
|
--propertygrid-disabled-background: rgb(170 170 170 / 14%);
|
|
15
15
|
--propertygrid-hover-background: var(--color-primary-a10);
|
|
@@ -54,8 +54,6 @@
|
|
|
54
54
|
padding: 6px 4px 4px 4px;
|
|
55
55
|
min-height: 2em;
|
|
56
56
|
background-color: var( --propertygrid-group-background );
|
|
57
|
-
margin-bottom: 8px;
|
|
58
|
-
box-shadow: 4px 3px 8px rgba(0, 0, 0, 0.4);
|
|
59
57
|
color: var( --propertygrid-group-color );
|
|
60
58
|
display: flex;
|
|
61
59
|
align-items: center;
|
|
@@ -81,9 +79,14 @@
|
|
|
81
79
|
font-weight: bold;
|
|
82
80
|
}
|
|
83
81
|
|
|
82
|
+
// the button that folds the group: as high as the title, its icon in the colour
|
|
83
|
+
// of the group when it has the focus (its background stays transparent)
|
|
84
84
|
&>.x4button {
|
|
85
|
+
--button-color-focus: var( --propertygrid-group-color );
|
|
85
86
|
background-color: transparent;
|
|
86
87
|
color: var( --propertygrid-group-color );
|
|
88
|
+
margin: 0;
|
|
89
|
+
padding: 0;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
&>.x4label {
|
|
@@ -99,14 +102,19 @@
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
// under the title, in the box of the header
|
|
106
|
+
.desc {
|
|
103
107
|
font-style: italic;
|
|
104
108
|
font-weight: normal;
|
|
105
|
-
font-size:
|
|
109
|
+
font-size: 80%;
|
|
110
|
+
padding-top: 4px;
|
|
111
|
+
padding-left: 1em;
|
|
106
112
|
color: var( --text-ternary );
|
|
107
113
|
}
|
|
108
114
|
|
|
115
|
+
// the space is between the last row of a group and the next group, none under the title
|
|
109
116
|
&:not(:first-child ) {
|
|
117
|
+
margin-top: 8px;
|
|
110
118
|
//border-top: 1px solid var(--color-primary-a50);
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -16,8 +16,36 @@
|
|
|
16
16
|
|
|
17
17
|
:root {
|
|
18
18
|
--radio-background: var( --background-primary );
|
|
19
|
+
--radio-check-background: var( --accent-background );
|
|
19
20
|
--radio-check-color: var( --accent-color );
|
|
20
|
-
--radio-
|
|
21
|
+
--radio-focus: var( --accent-background );
|
|
22
|
+
|
|
23
|
+
--radio-button-border: var( --border );
|
|
24
|
+
--radio-button-color: var( --text-ternary );
|
|
25
|
+
--radio-button-color-checked: var( --accent-background-active );
|
|
26
|
+
--radio-button-background-checked: var( --background-secondary );
|
|
27
|
+
--radio-button-border-checked: var( --accent-background );
|
|
28
|
+
|
|
29
|
+
--radio-danger-color: var( --alert-background );
|
|
30
|
+
--radio-danger-background: var( --alert-background-light );
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// button: the round hidden, its input still in the page (not display: none),
|
|
34
|
+
// so that Tab and the arrows reach it; the focus of the keyboard shown around the radio
|
|
35
|
+
%radio-no-round {
|
|
36
|
+
gap: 0; // no space kept for the round
|
|
37
|
+
|
|
38
|
+
.inner {
|
|
39
|
+
width: 0;
|
|
40
|
+
height: 0;
|
|
41
|
+
overflow: hidden;
|
|
42
|
+
opacity: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
&:has(input:focus-visible) {
|
|
46
|
+
outline: 1px dotted var( --radio-focus );
|
|
47
|
+
outline-offset: 2px;
|
|
48
|
+
}
|
|
21
49
|
}
|
|
22
50
|
|
|
23
51
|
.x4radio {
|
|
@@ -69,7 +97,7 @@
|
|
|
69
97
|
height: 100%;
|
|
70
98
|
|
|
71
99
|
.fa-primary {
|
|
72
|
-
fill: var( --
|
|
100
|
+
fill: var( --radio-check-background );
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
.fa-secondary {
|
|
@@ -98,20 +126,20 @@
|
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
128
|
|
|
101
|
-
|
|
129
|
+
// square: its former name, kept for the code that has it (not documented)
|
|
130
|
+
.x4radio.button,
|
|
131
|
+
.x4radio.square {
|
|
102
132
|
--size: 24px;
|
|
103
133
|
|
|
104
134
|
&.outline {
|
|
105
135
|
border-radius: 22px;;
|
|
106
136
|
}
|
|
107
137
|
|
|
108
|
-
border: 1px solid
|
|
138
|
+
border: 1px solid var( --radio-button-border );
|
|
109
139
|
padding: 8px;
|
|
110
140
|
margin: 0;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
display: none;
|
|
114
|
-
}
|
|
141
|
+
|
|
142
|
+
@extend %radio-no-round;
|
|
115
143
|
|
|
116
144
|
label {
|
|
117
145
|
display: flex;
|
|
@@ -121,49 +149,44 @@
|
|
|
121
149
|
#text {
|
|
122
150
|
flex-grow: 1;
|
|
123
151
|
text-align: center;
|
|
124
|
-
color: var( --
|
|
152
|
+
color: var( --radio-button-color );
|
|
125
153
|
}
|
|
126
|
-
|
|
154
|
+
|
|
155
|
+
// fill and color: an icon is drawn in its fill, or in currentColor
|
|
127
156
|
#icon {
|
|
128
|
-
fill: var( --
|
|
157
|
+
fill: var( --radio-button-color );
|
|
158
|
+
color: var( --radio-button-color );
|
|
129
159
|
width: var( --size );
|
|
130
160
|
height: var( --size );
|
|
131
161
|
}
|
|
132
162
|
}
|
|
133
163
|
|
|
134
164
|
&:has(input:checked) {
|
|
135
|
-
background-color: var( --background-
|
|
136
|
-
border-color:
|
|
165
|
+
background-color: var( --radio-button-background-checked );
|
|
166
|
+
border-color: var( --radio-button-border-checked );
|
|
137
167
|
|
|
138
168
|
.x4label {
|
|
169
|
+
// no bold: the width of the button would change with its state
|
|
139
170
|
#text {
|
|
140
|
-
color: var( --
|
|
141
|
-
font-weight: bold;
|
|
171
|
+
color: var( --radio-button-color-checked );
|
|
142
172
|
}
|
|
143
173
|
|
|
144
174
|
#icon {
|
|
145
|
-
fill: var( --
|
|
175
|
+
fill: var( --radio-button-color-checked );
|
|
176
|
+
color: var( --radio-button-color-checked );
|
|
146
177
|
}
|
|
147
178
|
|
|
148
179
|
.fa-primary {
|
|
149
|
-
fill: var( --
|
|
180
|
+
fill: var( --radio-button-color-checked );
|
|
150
181
|
}
|
|
151
182
|
}
|
|
152
183
|
}
|
|
153
184
|
}
|
|
154
185
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
display: none;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
&:has(input:checked) {
|
|
165
|
-
background-color: var( --accent-background-active );
|
|
166
|
-
border-color: var( --radio-square-border-checked );
|
|
167
|
-
font-weight: 700;
|
|
168
|
-
}
|
|
186
|
+
// danger: checked in the red of the alerts, its round or its button
|
|
187
|
+
.x4radio.danger {
|
|
188
|
+
--radio-check-background: var( --radio-danger-color );
|
|
189
|
+
--radio-button-color-checked: var( --radio-danger-color );
|
|
190
|
+
--radio-button-background-checked: var( --radio-danger-background );
|
|
191
|
+
--radio-button-border-checked: var( --radio-danger-color );
|
|
169
192
|
}
|
|
@@ -47,12 +47,23 @@ interface RadioProps extends ComponentProps {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
+
* A radio: the ones with the same name are a group. Its look, by its class (cls): "button"
|
|
51
|
+
* (a button, "button outline" rounded), "danger" (checked in red, its round or its button).
|
|
52
|
+
* Without the round, its input is still reached by Tab.
|
|
50
53
|
*
|
|
51
54
|
* @cssvar
|
|
52
55
|
* ```
|
|
53
56
|
* --radio-background
|
|
57
|
+
* --radio-check-background
|
|
54
58
|
* --radio-check-color
|
|
55
|
-
* --radio-
|
|
59
|
+
* --radio-focus
|
|
60
|
+
* --radio-button-border
|
|
61
|
+
* --radio-button-color
|
|
62
|
+
* --radio-button-color-checked
|
|
63
|
+
* --radio-button-background-checked
|
|
64
|
+
* --radio-button-border-checked
|
|
65
|
+
* --radio-danger-color
|
|
66
|
+
* --radio-danger-background
|
|
56
67
|
* ```
|
|
57
68
|
*/
|
|
58
69
|
|
|
@@ -104,7 +104,7 @@ export class Rating extends HBox<RatingProps,RatingEventMap> {
|
|
|
104
104
|
|
|
105
105
|
// the star clicked: its svg, or the Icon itself
|
|
106
106
|
private _on_click( ev: MouseEvent ) {
|
|
107
|
-
const n = this.m_els.findIndex( c => c.
|
|
107
|
+
const n = this.m_els.findIndex( c => c.contains( ev.target as Node ) );
|
|
108
108
|
if( n>=0 ) {
|
|
109
109
|
// the star of the value, clicked again: none is lit
|
|
110
110
|
this.setValue( n+1===this.props.value ? 0 : n+1 );
|
|
@@ -167,7 +167,9 @@ export class CSizer extends Component<ComponentProps,CSizerEvent> {
|
|
|
167
167
|
if( this._type.includes("-prev") || this._type.includes("-next") ) {
|
|
168
168
|
const neighbour = this._type.includes("-prev") ? this.prevElement( ) : this.nextElement( );
|
|
169
169
|
if( neighbour ) {
|
|
170
|
-
|
|
170
|
+
// shrink 1: no larger than the space the box has left (the flex neighbour at its
|
|
171
|
+
// padding); 0 at least, a negative basis is not a value
|
|
172
|
+
this._ref.setStyleValue( "flex", `0 1 ${Math.max( 0, size )}px` );
|
|
171
173
|
}
|
|
172
174
|
else {
|
|
173
175
|
this._ref.setStyle( nr );
|
|
@@ -185,18 +187,28 @@ export class CSizer extends Component<ComponentProps,CSizerEvent> {
|
|
|
185
187
|
}
|
|
186
188
|
}
|
|
187
189
|
|
|
190
|
+
/**
|
|
191
|
+
* a sizer between two children of a HBox: it resizes the one before it or the one after it
|
|
192
|
+
* (a boolean, as in older code: true after, false before)
|
|
193
|
+
*/
|
|
194
|
+
|
|
188
195
|
export class HSizer extends CSizer {
|
|
189
196
|
static classPrefix = "x4";
|
|
190
197
|
|
|
191
|
-
constructor(
|
|
192
|
-
super(
|
|
198
|
+
constructor( side: "before" | "after" | boolean = "after" ) {
|
|
199
|
+
super( side === "before" || side === false ? "hsize-prev" : "hsize-next" );
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
202
|
|
|
203
|
+
/**
|
|
204
|
+
* a sizer between two children of a VBox: it resizes the one before it (above) or the one
|
|
205
|
+
* after it (under); a boolean as for HSizer
|
|
206
|
+
*/
|
|
207
|
+
|
|
196
208
|
export class VSizer extends CSizer {
|
|
197
209
|
static classPrefix = "x4";
|
|
198
210
|
|
|
199
|
-
constructor(
|
|
200
|
-
super(
|
|
211
|
+
constructor( side: "before" | "after" | boolean = "after" ) {
|
|
212
|
+
super( side === "before" || side === false ? "vsize-prev" : "vsize-next" );
|
|
201
213
|
}
|
|
202
214
|
}
|
|
@@ -72,6 +72,11 @@
|
|
|
72
72
|
background-color: var( --spreadsheet-background );
|
|
73
73
|
border: 1px solid var( --spreadsheet-border );
|
|
74
74
|
color: var( --spreadsheet-cell-color );
|
|
75
|
+
|
|
76
|
+
// disabled: faded as a Box (a Spreadsheet is not one)
|
|
77
|
+
&[disabled] {
|
|
78
|
+
opacity: var( --box-disabled-opacity );
|
|
79
|
+
}
|
|
75
80
|
|
|
76
81
|
.cell {
|
|
77
82
|
padding: 4px;
|
|
@@ -179,6 +184,7 @@
|
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
.percent {
|
|
187
|
+
align-self: center; // a Box stretches (top of the cell): centered in the row
|
|
182
188
|
width: 100%;
|
|
183
189
|
border: 1px solid var( --border );
|
|
184
190
|
height: 8px;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
--tab-color-hover: var( --text-primary );
|
|
7
7
|
--tab-color: var( --text-primary );
|
|
8
8
|
--tab-color-selected: var( --text-primary );
|
|
9
|
+
--tab-icon-color: var( --icon-color );
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
.x4tabs {
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
border: none;
|
|
25
26
|
border-bottom: 2px solid transparent;
|
|
26
27
|
color: var( --tab-color );
|
|
28
|
+
--ol-button-icon-color: var( --tab-icon-color );
|
|
27
29
|
margin: 0;
|
|
28
30
|
outline: none;
|
|
29
31
|
transition: border-color 0.2s ease;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--textarea-selection-color: var( --accent-color );
|
|
24
24
|
--textarea-background: var( --background-input );
|
|
25
25
|
--textarea-color: var( --text-primary );
|
|
26
|
+
--textarea-required: var( --alert-background );
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
.x4simpletextarea {
|
|
@@ -77,6 +78,14 @@
|
|
|
77
78
|
display: none;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// required: a "*" before the label, as the one of a TextEdit
|
|
82
|
+
&:has( > .x4simpletextarea[required] ) > .x4label::before {
|
|
83
|
+
content: "*";
|
|
84
|
+
font-weight: bold;
|
|
85
|
+
color: var( --textarea-required );
|
|
86
|
+
margin-right: 2px;
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
.x4simpletextarea {
|
|
81
90
|
// its height of rows when the TextArea has none, the TextArea's height when it has one;
|
|
82
91
|
// a basis of 0 would leave it the height of its padding: a textarea scrolls, its min-height is 0
|
|
@@ -52,6 +52,7 @@ $color--interval: 8.4%;
|
|
|
52
52
|
--background-primary: var(--color-primary-a0);
|
|
53
53
|
--background-secondary: var(--color-primary-a10);
|
|
54
54
|
--background-ternary: color-mix(in srgb, var(--color-primary-a100) 4.3%, var(--color-primary-a0)); // #f4f4f4
|
|
55
|
+
--background-odd: color-mix(in srgb, var(--color-primary-a100) 2.4%, var(--color-primary-a0)); // #f9f9f9, a row out of two
|
|
55
56
|
--background-hover: color-mix(in srgb, var(--color-primary-a100) 6.7%, var(--color-primary-a0)); // #eee
|
|
56
57
|
--background-active: color-mix(in srgb, var(--color-primary-a100) 20%, var(--color-primary-a0)); // #ccc
|
|
57
58
|
--background-input: color-mix(in srgb, var(--color-primary-a0) 60%, transparent); // white at 60%
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ___ ___ __
|
|
3
|
+
* \ \/ / / _
|
|
4
|
+
* \ / /_| |_
|
|
5
|
+
* / \____ _|
|
|
6
|
+
* /__/\__\ |_|
|
|
7
|
+
*
|
|
8
|
+
* @file timeline.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
|
+
--timeline-done-color: var( --accent-background );
|
|
19
|
+
--timeline-done-icon-color: var( --accent-color );
|
|
20
|
+
--timeline-todo-color: var( --border );
|
|
21
|
+
--timeline-todo-icon-color: var( --text-ternary );
|
|
22
|
+
--timeline-title-color: var( --text-primary );
|
|
23
|
+
--timeline-text-color: var( --text-ternary );
|
|
24
|
+
--timeline-time-color: var( --text-primary );
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// side by side: the marks on a line at the top, the content under each one
|
|
28
|
+
|
|
29
|
+
.x4timeline {
|
|
30
|
+
display: flex;
|
|
31
|
+
flex-direction: row;
|
|
32
|
+
|
|
33
|
+
.item {
|
|
34
|
+
display: flex;
|
|
35
|
+
flex-direction: column;
|
|
36
|
+
flex: 1 1 0;
|
|
37
|
+
min-width: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.rail {
|
|
41
|
+
display: flex;
|
|
42
|
+
flex-direction: row;
|
|
43
|
+
align-items: center;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.mark {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
flex-shrink: 0;
|
|
51
|
+
width: 24px;
|
|
52
|
+
height: 24px;
|
|
53
|
+
border-radius: 50%;
|
|
54
|
+
background-color: var( --timeline-todo-color );
|
|
55
|
+
|
|
56
|
+
.x4icon {
|
|
57
|
+
height: 12px;
|
|
58
|
+
color: var( --timeline-todo-icon-color );
|
|
59
|
+
fill: var( --timeline-todo-icon-color );
|
|
60
|
+
|
|
61
|
+
&.empty {
|
|
62
|
+
display: none;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// the line to the next mark: only one side has a width
|
|
68
|
+
.line {
|
|
69
|
+
flex: 1;
|
|
70
|
+
border: 0 dashed var( --timeline-todo-color );
|
|
71
|
+
border-top-width: 2px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.item.done > .rail {
|
|
75
|
+
.mark {
|
|
76
|
+
background-color: var( --timeline-done-color );
|
|
77
|
+
|
|
78
|
+
.x4icon {
|
|
79
|
+
color: var( --timeline-done-icon-color );
|
|
80
|
+
fill: var( --timeline-done-icon-color );
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.line {
|
|
85
|
+
border-style: solid;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.item.next-done > .rail > .line {
|
|
90
|
+
border-color: var( --timeline-done-color );
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.item:last-child > .rail > .line {
|
|
94
|
+
display: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.content {
|
|
98
|
+
padding: 8px 12px 0 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.title {
|
|
102
|
+
font-weight: bold;
|
|
103
|
+
color: var( --timeline-title-color );
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.text {
|
|
107
|
+
color: var( --timeline-text-color );
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.time {
|
|
111
|
+
margin-top: 4px;
|
|
112
|
+
font-size: 0.85em;
|
|
113
|
+
font-weight: 500;
|
|
114
|
+
color: var( --timeline-time-color );
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// one under the other: the marks on a line on the left, the content beside each one
|
|
118
|
+
|
|
119
|
+
&.vertical {
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
|
|
122
|
+
.item {
|
|
123
|
+
flex-direction: row;
|
|
124
|
+
flex: 0 0 auto;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.rail {
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.line {
|
|
132
|
+
border-top-width: 0;
|
|
133
|
+
border-left-width: 2px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.content {
|
|
137
|
+
flex: 1;
|
|
138
|
+
min-width: 0;
|
|
139
|
+
padding: 0 0 1.5em 12px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// the title on the middle of the mark
|
|
143
|
+
.title {
|
|
144
|
+
line-height: 24px;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ___ ___ __
|
|
3
|
+
* \ \/ / / _
|
|
4
|
+
* \ / /_| |_
|
|
5
|
+
* / \____ _|
|
|
6
|
+
* /__/\__\ |_|
|
|
7
|
+
*
|
|
8
|
+
* @file timeline.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, ComponentEvents, ComponentProps, EvClick } from '../../core/component';
|
|
18
|
+
import { EventCallback } from '../../core/core_events';
|
|
19
|
+
import { UnsafeHtml } from '../../core/core_tools';
|
|
20
|
+
import { getCurrentLocale } from '../../core/core_i18n';
|
|
21
|
+
import { Icon } from '../icon/icon';
|
|
22
|
+
|
|
23
|
+
import "./timeline.module.scss";
|
|
24
|
+
|
|
25
|
+
export interface TimelineItem {
|
|
26
|
+
icon?: string; // in the round mark
|
|
27
|
+
title: string | UnsafeHtml; // in bold
|
|
28
|
+
text?: string | UnsafeHtml; // the sentence under it
|
|
29
|
+
date?: Date; // written relative to now, in the locale: "2 hours ago"; none: no time shown
|
|
30
|
+
done?: boolean; // the mark filled in the accent; else a grey mark
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TimelineEvents extends ComponentEvents {
|
|
34
|
+
click: EvClick; // context: the TimelineItem clicked
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface TimelineProps extends ComponentProps {
|
|
38
|
+
items?: TimelineItem[];
|
|
39
|
+
vertical?: boolean; // true by default; false: the events side by side
|
|
40
|
+
renderer?: ( item: TimelineItem ) => Component; // what is beside the mark
|
|
41
|
+
click?: EventCallback<EvClick>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* "2 hours ago", "yesterday", "in 3 days": the largest unit that gives at least 1
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
function relativeTime( date: Date ): string {
|
|
49
|
+
const fmt = new Intl.RelativeTimeFormat( getCurrentLocale( ), { numeric: "auto" } );
|
|
50
|
+
const sec = ( date.getTime( ) - Date.now( ) ) / 1000; // < 0: in the past
|
|
51
|
+
const abs = Math.abs( sec );
|
|
52
|
+
|
|
53
|
+
if( abs < 60 ) {
|
|
54
|
+
return fmt.format( Math.trunc( sec ), "second" );
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if( abs < 3600 ) {
|
|
58
|
+
return fmt.format( Math.trunc( sec / 60 ), "minute" );
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if( abs < 86400 ) {
|
|
62
|
+
return fmt.format( Math.trunc( sec / 3600 ), "hour" );
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if( abs < 86400 * 30 ) {
|
|
66
|
+
return fmt.format( Math.trunc( sec / 86400 ), "day" );
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if( abs < 86400 * 365 ) {
|
|
70
|
+
return fmt.format( Math.trunc( sec / ( 86400 * 30 ) ), "month" );
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return fmt.format( Math.trunc( sec / ( 86400 * 365 ) ), "year" );
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Events one after the other on a line, each with its icon in a round mark.
|
|
78
|
+
* The line from a mark to the next one is in the accent when the next event is done,
|
|
79
|
+
* dashed when the event it leaves is not done.
|
|
80
|
+
*
|
|
81
|
+
* @cssvar
|
|
82
|
+
* ```
|
|
83
|
+
* --timeline-done-color
|
|
84
|
+
* --timeline-done-icon-color
|
|
85
|
+
* --timeline-todo-color
|
|
86
|
+
* --timeline-todo-icon-color
|
|
87
|
+
* --timeline-title-color
|
|
88
|
+
* --timeline-text-color
|
|
89
|
+
* --timeline-time-color
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
export class Timeline extends Component<TimelineProps, TimelineEvents> {
|
|
94
|
+
static classPrefix = "x4";
|
|
95
|
+
|
|
96
|
+
constructor( props: TimelineProps ) {
|
|
97
|
+
super( props );
|
|
98
|
+
|
|
99
|
+
this.setClass( "vertical", props.vertical ?? true );
|
|
100
|
+
|
|
101
|
+
this.mapPropEvents( props, "click" );
|
|
102
|
+
this.addDOMEvent( "click", ( ev ) => this._onClick( ev ) );
|
|
103
|
+
|
|
104
|
+
if( props.items ) {
|
|
105
|
+
this.setItems( props.items );
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
setItems( items: TimelineItem[] ) {
|
|
110
|
+
const renderer = this.props.renderer;
|
|
111
|
+
|
|
112
|
+
const content = items.map( ( item, i ) => {
|
|
113
|
+
const body = renderer ? renderer( item ) : [
|
|
114
|
+
new Component( { cls: "title", content: item.title } ),
|
|
115
|
+
item.text ? new Component( { cls: "text", content: item.text } ) : null,
|
|
116
|
+
item.date ? new Component( { cls: "time", content: relativeTime( item.date ) } ) : null,
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const el = new Component( { cls: "item", content: [
|
|
120
|
+
new Component( { cls: "rail", content: [
|
|
121
|
+
new Component( { cls: "mark", content: new Icon( { iconId: item.icon } ) } ),
|
|
122
|
+
new Component( { cls: "line" } ),
|
|
123
|
+
] } ),
|
|
124
|
+
new Component( { cls: "content", content: body } ),
|
|
125
|
+
] } );
|
|
126
|
+
|
|
127
|
+
el.setClass( "done", !!item.done );
|
|
128
|
+
el.setClass( "next-done", i + 1 < items.length && !!items[i + 1].done );
|
|
129
|
+
el.setInternalData( "item", item );
|
|
130
|
+
return el;
|
|
131
|
+
} );
|
|
132
|
+
|
|
133
|
+
this.setContent( content );
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* a click anywhere in an event: its item
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
private _onClick( ev: MouseEvent ) {
|
|
141
|
+
const el = Component.closest( ev.target as Element, ".x4timeline > .item" );
|
|
142
|
+
if( el ) {
|
|
143
|
+
this.fire( "click", { context: el.getInternalData( "item" ) } );
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|