x4js 2.3.26 → 2.3.27

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
@@ -36,7 +36,7 @@ CoreElement<E> named timers + event surface (no DOM)
36
36
  │ │ ├── BtnGroup, Tabs, Video, Saturation
37
37
  │ ├── Button, Input, Icon, Label, SimpleText, Checkbox, Radio, Link, SimpleTextArea
38
38
  │ ├── Listbox, Gridview, Treeview, Select, Combobox, Slider, Gauge
39
- │ ├── Progress, TickLine, Canvas, Image, Spreadsheet, MonacoEditor
39
+ │ ├── Progress, TickLine, Timeline, Canvas, Image, Spreadsheet, MonacoEditor
40
40
  │ ├── SvgComponent → Chart
41
41
  │ ├── CSizer → HSizer / VSizer
42
42
  │ ├── FileDialog, ScrollView, Viewport, DockingView, Flex, Space
@@ -227,12 +227,16 @@ queryAll(selector: string): Component[]
227
227
  firstChild<T>(): T; lastChild<T>(): T
228
228
  nextElement<T>(): T; prevElement<T>(): T
229
229
  parentElement<T>(cls?: Constructor<T>): T
230
+ closest<T>(selector: string): T // this one or an ancestor whose element matches the css selector; null if none
231
+ contains(other: Component | Node): boolean // other (a node, or the element of a component) is the element of this one or inside it
230
232
  childCount(): number
231
233
  enumChildComponents(recursive: boolean): Component[]
232
234
  enumChildNodes(recursive: boolean): Node[]
233
235
  visitChildren(cb: (el: Component) => boolean): void
234
236
  static parentElement<T>(dom: Node, cls?: Constructor<T>): T // starts at the parent of dom, not at dom:
235
237
  // test componentFromDOM(dom) first for the component of dom itself
238
+ static closest<T>(dom: Element, selector: string): T // starts at dom itself: in a click handler,
239
+ // Component.closest(ev.target as Element, '.item')
236
240
  ```
237
241
 
238
242
  ### Visibility / state / focus / geometry
@@ -614,6 +618,41 @@ class Rating extends HBox {
614
618
  }
615
619
  ```
616
620
 
621
+ ### `Timeline` — events on a line, each with its icon in a round mark
622
+
623
+ ```ts
624
+ interface TimelineItem {
625
+ icon?: string // in the round mark
626
+ title: string | UnsafeHtml // in bold
627
+ text?: string | UnsafeHtml // the sentence under it, in grey
628
+ date?: Date // written relative to now, in the locale: "il y a 2 heures", "yesterday"; none: no time shown
629
+ done?: boolean // the mark in the accent; else a grey mark
630
+ }
631
+ interface TimelineProps extends ComponentProps {
632
+ items?: TimelineItem[]
633
+ vertical?: boolean // true by default; false: the events side by side
634
+ renderer?: (item: TimelineItem) => Component // what is beside the mark (the mark and the line stay)
635
+ click?: EventCallback<EvClick> // a click anywhere in an event: ev.context is its TimelineItem
636
+ }
637
+ interface TimelineEvents extends ComponentEvents { click: EvClick }
638
+ class Timeline extends Component<TimelineProps, TimelineEvents> {
639
+ setItems(items: TimelineItem[]): void // the relative times are written here, not updated after
640
+ }
641
+ // the line to the next mark: in the accent when the next event is done, dashed after an event not done
642
+ // CSS vars: --timeline-done-color --timeline-done-icon-color --timeline-todo-color --timeline-todo-icon-color
643
+ // --timeline-title-color --timeline-text-color --timeline-time-color
644
+ ```
645
+
646
+ ```ts
647
+ new Timeline({
648
+ items: [
649
+ { icon: branch, title: 'New branch', text: 'fix-notifications, from master', date: created, done: true },
650
+ { icon: review, title: 'Code review', text: 'Waiting for a reviewer' },
651
+ ],
652
+ click: ev => this.openStep(ev.context as TimelineItem),
653
+ })
654
+ ```
655
+
617
656
  ---
618
657
 
619
658
  ## Input components
@@ -1453,6 +1492,7 @@ component.addShortcut(keys, callback, options?) // a view: while the foc
1453
1492
  Application.instance().addShortcut(keys, callback, options?) // the whole application, wherever the focus is
1454
1493
 
1455
1494
  shortcutText(keys: string): string // for a tooltip or a menu: "Ctrl+Z", "⌘Z" on a Mac
1495
+ isMac: boolean // true on a Mac, an iPhone or an iPad: Mod is Cmd there
1456
1496
 
1457
1497
  class Shortcuts { // the list itself; rarely used directly
1458
1498
  add(keys: string | string[], callback: (ev: KeyboardEvent) => void, options?: ShortcutOptions): void
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x4js",
3
- "version": "2.3.26",
3
+ "version": "2.3.27",
4
4
  "type": "module",
5
5
  "main": "src/x4.ts",
6
6
  "module": "src/x4.ts",
@@ -268,7 +268,7 @@ export class AutoComplete extends TextEdit<AutoCompleteEvents> {
268
268
  private _on_focusout( ev: FocusEvent ) {
269
269
  // focus is moving inside the control
270
270
  const to = ev.relatedTarget as Node;
271
- if( to && this.dom.contains( to ) ) {
271
+ if( to && this.contains( to ) ) {
272
272
  return;
273
273
  }
274
274
 
@@ -358,7 +358,7 @@ export class PopupCalendar extends Popup {
358
358
  let newfocus = <HTMLElement>e.target;
359
359
 
360
360
  // child of this: ok
361
- if (this.dom.contains(newfocus)) {
361
+ if (this.contains(newfocus)) {
362
362
  return;
363
363
  }
364
364
 
@@ -264,7 +264,7 @@ export class Chart extends SvgComponent<ChartProps,ChartEvents> {
264
264
 
265
265
  this.addDOMEvent( "click", ( e ) => {
266
266
  const el = ( e.target as Element ).closest( "[data-index]" );
267
- if( el && this.dom.contains( el ) ) {
267
+ if( el && this.contains( el ) ) {
268
268
  const serie = parseInt( el.getAttribute( "data-serie" ) );
269
269
  const index = parseInt( el.getAttribute( "data-index" ) );
270
270
  this.fire( "click", { serie, index, value: this._series[serie]?.values[index] } );
@@ -45,6 +45,7 @@ export * from "./tag/tag"
45
45
  export * from "./textarea/textarea"
46
46
  export * from "./textedit/textedit"
47
47
  export * from "./tickline/tickline"
48
+ export * from "./timeline/timeline"
48
49
  export * from "./tooltips/tooltips"
49
50
  export * from "./treeview/treeview"
50
51
  export * from "./video/video"
@@ -404,7 +404,7 @@ export class DockingView extends Component<DockingViewProps, DockingViewEvents>
404
404
  this.fills.forEach( fill => fill( ) );
405
405
  this.placeFloats( );
406
406
 
407
- if( focused instanceof HTMLElement && this.dom.contains( focused ) ) {
407
+ if( focused instanceof HTMLElement && this.contains( focused ) ) {
408
408
  focused.focus( );
409
409
  }
410
410
  }
@@ -14,7 +14,7 @@
14
14
  * that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
15
15
  **/
16
16
 
17
- import { Component, ComponentEvent, ComponentEvents, componentFromDOM, ComponentProps, EvChange, EvClick, EvContextMenu, EvDblClick, EvSelectionChange } from '../../core/component';
17
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvChange, EvClick, EvContextMenu, EvDblClick, EvSelectionChange } from '../../core/component';
18
18
  import { EventCallback } from '../../core/core_events';
19
19
  import { kbNav, isArray, UnsafeHtml, asap } from '../../core/core_tools';
20
20
 
@@ -283,47 +283,38 @@ private _view: Viewport;
283
283
  */
284
284
 
285
285
  private _on_click( ev: MouseEvent ) {
286
- let target = ev.target as HTMLElement;
287
- let on_check = false;
286
+ const target = ev.target as Element;
288
287
 
289
- while( target && target!=this.dom ) {
290
- const c = componentFromDOM( target );
288
+ // avoid trapping child click
289
+ if( Component.closest( target, "input" ) ) {
290
+ return;
291
+ }
291
292
 
292
- // avoid trapping child click
293
- if( c && c.dom.tagName==='INPUT' ) {
293
+ // an item of this list, not of a list around it
294
+ const c = Component.closest( target, ".x4item" );
295
+ if( c && this.contains( c ) ) {
296
+ // the case changes alone; a double click on it is ignored
297
+ if( Component.closest( target, ".x4check" ) ) {
298
+ if( ev.type == 'click' ) {
299
+ this._toggleCheck( c );
300
+ }
294
301
  return;
295
302
  }
296
303
 
297
- if( c && c.hasClass("x4check") ) {
298
- on_check = true;
304
+ const id = c.getInternalData( "id" );
305
+ const fev: EvClick = { context:id };
306
+ if (ev.type == 'click') {
307
+ this.fire('click', fev );
308
+ }
309
+ else {
310
+ this.fire('dblClick', fev );
299
311
  }
300
312
 
301
- if( c && c.hasClass("x4item") ) {
302
- // the case changes alone; a double click on it is ignored
303
- if( on_check ) {
304
- if( ev.type == 'click' ) {
305
- this._toggleCheck( c );
306
- }
307
- return;
308
- }
309
-
310
- const id = c.getInternalData( "id" );
311
- const fev: EvClick = { context:id };
312
- if (ev.type == 'click') {
313
- this.fire('click', fev );
314
- }
315
- else {
316
- this.fire('dblClick', fev );
317
- }
318
-
319
- if (!fev.defaultPrevented) {
320
- this._selectItem( id, c, ev.ctrlKey ? 'toggle' : 'single', ev.type == 'click' && this.props.reselect );
321
- }
322
-
323
- return;
313
+ if (!fev.defaultPrevented) {
314
+ this._selectItem( id, c, ev.ctrlKey ? 'toggle' : 'single', ev.type == 'click' && this.props.reselect );
324
315
  }
325
316
 
326
- target = target.parentElement;
317
+ return;
327
318
  }
328
319
 
329
320
  if (ev.type == 'click') {
@@ -344,19 +335,14 @@ private _view: Viewport;
344
335
 
345
336
  ev.preventDefault();
346
337
 
347
- let target = ev.target as HTMLElement;
348
- while( target && target!=this.dom ) {
349
- const c = componentFromDOM( target );
350
- if( c && c.hasClass("x4item") ) {
351
- const id = c.getInternalData( "id" );
352
-
353
- this._selectItem(id, c, 'single' );
354
- this.fire('contextMenu', {uievent: ev, context: id } );
355
-
356
- return;
357
- }
338
+ const c = Component.closest( ev.target as Element, ".x4item" );
339
+ if( c && this.contains( c ) ) {
340
+ const id = c.getInternalData( "id" );
358
341
 
359
- target = target.parentElement;
342
+ this._selectItem(id, c, 'single' );
343
+ this.fire('contextMenu', {uievent: ev, context: id } );
344
+
345
+ return;
360
346
  }
361
347
 
362
348
  this.fire('contextMenu', { uievent:ev, context: null } );
@@ -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( );
@@ -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 );
@@ -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
+ }
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { asap } from '../../core/core_tools';
18
18
  import { EventCallback } from '../../core/core_events';
19
- import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvClick, EvDblClick, EvSelectionChange, componentFromDOM } from '../../core/component';
19
+ import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvClick, EvDblClick, EvSelectionChange } from '../../core/component';
20
20
 
21
21
  import { ScrollView, Viewport } from '../viewport/viewport';
22
22
  import { Label, SimpleText } from '../label/label';
@@ -244,30 +244,24 @@ export class Treeview extends Component<TreeviewProps,TreeviewEvents> {
244
244
  }
245
245
 
246
246
  private _on_click( ev: UIEvent ) {
247
- let target = ev.target as HTMLElement;
248
-
249
- while( target && target!=this.dom ) {
250
- const c = componentFromDOM( target );
251
-
252
- if( c && c.hasClass("item") ) {
253
- const id = c.getInternalData( "id" );
254
-
255
- const fev: ComponentEvent = { context:id };
256
- if (ev.type == 'click') {
257
- this.fire('click', fev );
258
- }
259
- else {
260
- this.fire('dblClick', fev );
261
- }
262
-
263
- if (!fev.defaultPrevented) {
264
- this._selectItem( id, c );
265
- }
247
+ // an item of this tree, not of a component around it
248
+ const c = Component.closest( ev.target as Element, ".item" );
249
+ if( c && this.contains( c ) ) {
250
+ const id = c.getInternalData( "id" );
251
+
252
+ const fev: ComponentEvent = { context:id };
253
+ if (ev.type == 'click') {
254
+ this.fire('click', fev );
255
+ }
256
+ else {
257
+ this.fire('dblClick', fev );
258
+ }
266
259
 
267
- return;
260
+ if (!fev.defaultPrevented) {
261
+ this._selectItem( id, c );
268
262
  }
269
263
 
270
- target = target.parentElement;
264
+ return;
271
265
  }
272
266
 
273
267
  this.clearSelection( );
@@ -959,7 +959,24 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
959
959
  */
960
960
 
961
961
  parentElement<T extends Component>( cls?: Constructor<T> ): T {
962
- return Component.parentElement<T>( this.dom, cls );
962
+ return Component.parentElement<T>( this.dom, cls );
963
+ }
964
+
965
+ /**
966
+ * The nearest component whose element matches a css selector: this one, or one of its ancestors.
967
+ * @returns null when there is none
968
+ */
969
+
970
+ closest<T extends Component = Component>( selector: string ): T {
971
+ return Component.closest<T>( this.dom, selector );
972
+ }
973
+
974
+ /**
975
+ * true when a node, or the element of a component, is the element of this one or inside it
976
+ */
977
+
978
+ contains( other: Component | Node ): boolean {
979
+ return this.dom.contains( other instanceof Component ? other.dom : other );
963
980
  }
964
981
 
965
982
  /**
@@ -992,10 +1009,22 @@ export class Component<P extends ComponentProps = ComponentProps, E extends Comp
992
1009
 
993
1010
  dom = dom.parentElement;
994
1011
  }
995
-
1012
+
996
1013
  return null;
997
1014
  }
998
1015
 
1016
+ /**
1017
+ * The nearest component whose element matches a css selector: the one of dom, or one of its ancestors.
1018
+ * @returns null when there is none
1019
+ * @example
1020
+ * // in a click handler: the item hit, wherever the click is in it
1021
+ * const item = Component.closest( ev.target as Element, ".x4timeline > .item" );
1022
+ */
1023
+
1024
+ static closest<T extends Component = Component>( dom: Element, selector: string ): T {
1025
+ return componentFromDOM<T>( dom.closest( selector ) );
1026
+ }
1027
+
999
1028
  /**
1000
1029
  * Returns the first child element as a Component instance.
1001
1030
  * @returns The first child component, or `null` if none exists.
@@ -51,7 +51,8 @@ interface Keys {
51
51
  key: string;
52
52
  }
53
53
 
54
- const isMac = /mac|iphone|ipad/i.test( navigator.platform ?? "" );
54
+ /** true on a Mac, an iPhone or an iPad: Mod is Cmd there */
55
+ export const isMac = /mac|iphone|ipad/i.test( navigator.platform ?? "" );
55
56
 
56
57
  /**
57
58
  * "Shift+Mod+K" -> shift, ctrl (cmd on a Mac), k