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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -0
  3. package/aicontext.md +73 -6
  4. package/package.json +2 -1
  5. package/src/components/autocomplete/autocomplete.ts +6 -2
  6. package/src/components/boxes/boxes.ts +2 -2
  7. package/src/components/button/button.module.scss +4 -3
  8. package/src/components/calendar/calendar.ts +1 -1
  9. package/src/components/chart/chart.ts +1 -1
  10. package/src/components/checkbox/checkbox.module.scss +67 -1
  11. package/src/components/checkbox/checkbox.ts +22 -6
  12. package/src/components/colorinput/colorinput.module.scss +1 -0
  13. package/src/components/combobox/combobox.module.scss +4 -1
  14. package/src/components/components.ts +1 -0
  15. package/src/components/dockingview/dockingview.ts +1 -1
  16. package/src/components/gridview/gridview.module.scss +6 -0
  17. package/src/components/input/input.module.scss +1 -1
  18. package/src/components/listbox/listbox.ts +31 -45
  19. package/src/components/messages/messages.module.scss +9 -4
  20. package/src/components/monaco/monaco.ts +1 -2
  21. package/src/components/popup/popup.ts +2 -2
  22. package/src/components/propgrid/progrid.module.scss +13 -5
  23. package/src/components/radio/radio.module.scss +54 -31
  24. package/src/components/radio/radio.ts +12 -1
  25. package/src/components/rating/rating.ts +1 -1
  26. package/src/components/sizers/sizer.ts +17 -5
  27. package/src/components/spreadsheet/spreadsheet.module.scss +6 -0
  28. package/src/components/tabs/tabs.module.scss +2 -0
  29. package/src/components/tabs/tabs.ts +1 -0
  30. package/src/components/textarea/textarea.module.scss +9 -0
  31. package/src/components/textarea/textarea.ts +1 -0
  32. package/src/components/themes.scss +1 -0
  33. package/src/components/timeline/timeline.module.scss +147 -0
  34. package/src/components/timeline/timeline.ts +146 -0
  35. package/src/components/treeview/treeview.module.scss +6 -1
  36. package/src/components/treeview/treeview.ts +17 -22
  37. package/src/components/video/video.module.scss +5 -2
  38. package/src/components/video/video.ts +14 -1
  39. package/src/core/component.ts +31 -2
  40. package/src/core/core_pdf.ts +17 -0
  41. package/src/core/core_react.ts +12 -18
  42. package/src/core/core_shortcuts.ts +2 -1
  43. 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.dom.contains( e.target as Node ) ) ) {
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.dom.contains( active ) );
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(--color-primary-a5);
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
- &>.desc {
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: 90%;
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-square-border-checked: var( --accent-color );
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( --accent-background)
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
- .x4radio.button {
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 transparent;
138
+ border: 1px solid var( --radio-button-border );
109
139
  padding: 8px;
110
140
  margin: 0;
111
-
112
- .inner {
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( --text-ternary );
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( --text-ternary );
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-secondary );
136
- border-color: var( --accent-background );
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( --accent-background-active );
141
- font-weight: bold;
171
+ color: var( --radio-button-color-checked );
142
172
  }
143
173
 
144
174
  #icon {
145
- fill: var( --accent-background-active );
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( --accent-background-active );
180
+ fill: var( --radio-button-color-checked );
150
181
  }
151
182
  }
152
183
  }
153
184
  }
154
185
 
155
- .x4radio.square {
156
- padding: 5px;
157
- border: 1px solid rgba(255,255,255,0.3);
158
- margin: 0;
159
-
160
- .inner {
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-square-border-checked
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.dom.contains( ev.target as Node ) );
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
- this._ref.setStyleValue( "flex", `0 0 ${size}px` );
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( next = true ) {
192
- super( next ? "hsize-next" : "hsize-prev" );
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( next = true ) {
200
- super( next ? "vsize-next" : "vsize-prev" );
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;
@@ -149,6 +149,7 @@ interface TabsProps extends Omit<ComponentProps,"content"> {
149
149
  * --tab-color-hover
150
150
  * --tab-color
151
151
  * --tab-color-selected
152
+ * --tab-icon-color
152
153
  * ```
153
154
  */
154
155
 
@@ -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
@@ -139,6 +139,7 @@ export class SimpleTextArea extends Component<SimpleTextAreaProps,TextAreaEvents
139
139
  * --textarea-selection-color
140
140
  * --textarea-background
141
141
  * --textarea-color
142
+ * --textarea-required
142
143
  * ```
143
144
  */
144
145
 
@@ -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
+ }