1+ import { Tree } from './tree.mjs' ;
12import { treeTemplate } from './templating.mjs' ;
2- import { unprefixId , assertRawTreeValid } from './helpers.mjs' ;
3+ import { unprefixId } from './helpers.mjs' ;
34import css from './cbx-tree.css?inline' ;
45
6+ /** @import {CbxRawTreeItem, CbxTreeItem, CbxTreeMap} from './tree.mjs' */
7+
58const stylesheet = new CSSStyleSheet ( ) ;
69stylesheet . replaceSync ( css ) ;
710
811
9- /**
10- * Raw user-defined data for a single item of the tree
11- * @typedef {object } CbxRawTreeItem
12- * @property {string } title - Item title
13- * @property {string } value - Item checkbox’s value, unique within the entire tree
14- * @property {string } [icon] - Item icon’s URL
15- * @property {boolean } [checked] - Item selection state
16- * @property {boolean } [collapsed] - Whether a children subtree is collapsed
17- * @property {CbxRawTreeItem[] | null } [children] - A list of child items, or `null` if subtree isn’t fetched yet
18- */
19-
20- /**
21- * Internal representation for a single item of the tree
22- * @typedef {object } CbxTreeItem
23- * @property {string } id - Item identifier, unique within the entire tree
24- * @property {string } title - Item title
25- * @property {string } value - Item checkbox’s value, unique within the entire tree
26- * @property {string } [icon] - Item icon’s URL
27- * @property {'checked' | 'unchecked' | 'indeterminate' } state - Computed state of the item’s selection
28- * @property {boolean } [collapsed] - Whether a children subtree is collapsed
29- * @property {CbxTreeMap | null } [children] - A map of child items, or `null` if subtree isn’t fetched yet
30- */
31-
32- /**
33- * Map ids to corresponding tree items
34- * @typedef {Map<string, CbxTreeItem> } CbxTreeMap
35- */
36-
3712export default class CbxTree extends HTMLElement {
3813 static get formAssociated ( ) {
3914 return true ;
@@ -49,11 +24,8 @@ export default class CbxTree extends HTMLElement {
4924 /** @type {ElementInternals } */
5025 #internals;
5126
52- /** @type {CbxTreeMap } */
53- #tree = new Map ( ) ;
54-
55- /** @type {Set<string> } */
56- #selection = new Set ( ) ;
27+ /** @type {Tree } */
28+ #tree;
5729
5830 /** @type {AbortController | null } */
5931 #hoverEventCtrl = null ;
@@ -83,8 +55,8 @@ export default class CbxTree extends HTMLElement {
8355 get formData ( ) {
8456 const data = new FormData ( ) ;
8557 const { name} = this ;
86- this . #selection. forEach ( ( id ) => {
87- const value = this . #getItem( id ) ?. value ;
58+ this . #tree . selection . forEach ( ( id ) => {
59+ const value = this . #tree . getItem ( id ) ?. value ;
8860 if ( value !== undefined ) {
8961 data . append ( name , value ) ;
9062 }
@@ -145,7 +117,7 @@ export default class CbxTree extends HTMLElement {
145117 }
146118
147119 this . #shadowRoot. addEventListener ( 'change' , ( e ) => this . #onChange( e ) ) ;
148- this . #shadowRoot. addEventListener ( 'click ' , ( e ) => this . #onItemToggle( e ) ) ;
120+ this . #shadowRoot. addEventListener ( 'pointerdown ' , ( e ) => this . #onItemToggle( e ) ) ;
149121 this . addEventListener ( 'focus' , ( ) => this . #onFocus( ) ) ;
150122 this . #shadowRoot. addEventListener ( 'keydown' , ( e ) => this . #onKeyDown( e ) ) ;
151123 this . #toggleHoverListener( ) ;
@@ -191,9 +163,10 @@ export default class CbxTree extends HTMLElement {
191163 }
192164 }
193165
194- #onItemToggle( { target} ) {
195- if ( target . part . contains ( 'toggle' ) ) {
196- this . #toggleItem( target . closest ( '[part="item"]' ) ) ;
166+ #onItemToggle( event ) {
167+ if ( event . isPrimary && event . target . part . contains ( 'toggle' ) ) {
168+ this . #toggleItem( event . target . closest ( '[part="item"]' ) ) ;
169+ event . preventDefault ( ) ; // prevent toggle button from grabbing focus
197170 }
198171 }
199172
@@ -265,7 +238,9 @@ export default class CbxTree extends HTMLElement {
265238 }
266239
267240 #onPointerOver( { target} ) {
268- const label = target . closest ( '[part="label"]' ) ;
241+ const label = target . part . contains ( 'toggle' ) ?
242+ target . closest ( '[part="item"]' ) . querySelector ( '[part="label"]' ) :
243+ target . closest ( '[part="label"]' ) ;
269244 this . #focusLabel( label , true ) ;
270245 }
271246
@@ -283,66 +258,30 @@ export default class CbxTree extends HTMLElement {
283258 }
284259
285260 #render( ) {
286- this . #shadowRoot. setHTMLUnsafe ( treeTemplate ( this . #tree) ) ;
261+ this . #shadowRoot. setHTMLUnsafe ( treeTemplate ( this . #tree. tree ) ) ;
287262 const checkboxes = this . #shadowRoot. querySelectorAll ( '[part="checkbox"]' ) ;
288263 [ ...checkboxes ] . forEach ( ( checkbox ) => {
289- const state = this . #getItem( unprefixId ( checkbox . id ) ) ?. state ;
264+ const state = this . #tree . getItem ( unprefixId ( checkbox . id ) ) ?. state ;
290265 checkbox . checked = state === 'checked' ;
291266 checkbox . indeterminate = state === 'indeterminate' ;
292267 } ) ;
293268 this . #focusedLabel = this . #shadowRoot. querySelector ( '[part="label"]' ) ;
294269 }
295270
296- /**
297- * Convert raw tree data to internal tree representation
298- * @param {CbxRawTreeItem[] } rawTree - Raw tree data
299- * @param {string } parentId - Identifier of a parent item (the case of building a subtree)
300- * @returns {CbxTreeMap }
301- */
302- #buildTree( rawTree , parentId ) {
303- return new Map ( rawTree . map ( ( rawItem , index ) => {
304- const id = parentId ? `${ parentId } :${ index } ` : String ( index ) ;
305- if ( rawItem . checked ) {
306- this . #selection. add ( id ) ;
307- }
308- /** @type {CbxTreeItem } */
309- const item = {
310- id,
311- title : rawItem . title ,
312- value : rawItem . value ,
313- icon : rawItem . icon ,
314- collapsed : rawItem . children ?. length ? ! ! rawItem . collapsed : ( rawItem . children === null ? true : undefined ) ,
315- children : rawItem . children ? this . #buildTree( rawItem . children , id ) : rawItem . children ,
316- } ;
317- Object . defineProperty ( item , 'state' , {
318- get : ( ) => {
319- if ( this . #selection. has ( item . id ) ) {
320- return 'checked' ;
321- }
322- if ( ! item . children ?. size ) {
323- return 'unchecked' ;
324- }
325- return this . #calcItemState( item ) ;
326- } ,
327- } ) ;
328- return [ id , item ] ;
329- } ) ) ;
330- }
331-
332271 async #requestSubtree( parentId ) {
333272 if ( typeof this . subtreeProvider !== 'function' ) {
334273 return ;
335274 }
336- const parentItem = this . #getItem( parentId ) ;
275+ const parentItem = this . #tree . getItem ( parentId ) ;
337276 if ( parentItem ?. children !== null ) {
338277 return ;
339278 }
340279 const itemElement = this . #shadowRoot. getElementById ( `item_${ parentId } ` ) ;
341280 itemElement . inert = true ;
342281 try {
343282 const subtree = await this . subtreeProvider ( parentItem . value ) ;
344- assertRawTreeValid ( subtree ) ;
345- parentItem . children = this . #buildTree ( subtree , parentItem . id ) ;
283+ Tree . assertRawTreeValid ( subtree ) ;
284+ this . #tree . setSubtree ( parentItem , subtree ) ;
346285 } finally {
347286 itemElement . inert = false ;
348287 }
@@ -357,35 +296,6 @@ export default class CbxTree extends HTMLElement {
357296 this . #refreshFormValue( ) ;
358297 }
359298
360- /**
361- * Get item object reference by item id
362- * @param {string } id - Item identifier
363- * @returns {CbxTreeItem | undefined }
364- */
365- #getItem( id ) {
366- const parts = id . split ( ':' ) ;
367- return parts . slice ( 1 ) . reduce ( ( item , part ) => item ?. children ?. get ( `${ item ?. id } :${ part } ` ) , this . #tree. get ( parts [ 0 ] ) ) ;
368- }
369-
370- /**
371- * Determine item state based on the states of its children
372- * @param {CbxTreeItem } item
373- * @returns {'checked' | 'unchecked' | 'indeterminate' }
374- */
375- #calcItemState( item ) {
376- const childrenStates = new Set ( [ ...item . children . values ( ) ] . map ( ( { state} ) => state ) ) ;
377- if ( childrenStates . has ( 'indeterminate' ) ) {
378- return 'indeterminate' ;
379- }
380- if ( ! childrenStates . has ( 'checked' ) ) {
381- return 'unchecked' ;
382- }
383- if ( ! childrenStates . has ( 'unchecked' ) ) {
384- return 'checked' ;
385- }
386- return 'indeterminate' ;
387- }
388-
389299 /**
390300 * Check/uncheck all items of a the tree or a subtree
391301 * @param {boolean } isChecked
@@ -397,7 +307,7 @@ export default class CbxTree extends HTMLElement {
397307 }
398308 const method = isChecked ? 'add' : 'delete' ;
399309 tree . forEach ( ( item , id ) => {
400- this . #selection[ method ] ( id ) ;
310+ this . #tree . selection [ method ] ( id ) ;
401311 const checkbox = this . #shadowRoot. getElementById ( `cbx_${ id } ` ) ;
402312 checkbox . checked = isChecked ;
403313 checkbox . indeterminate = false ;
@@ -421,7 +331,7 @@ export default class CbxTree extends HTMLElement {
421331 */
422332 #syncDescendants( item ) {
423333 if ( item . children ) {
424- this . #setAllChecked( this . #selection. has ( item . id ) , item . children ) ;
334+ this . #setAllChecked( this . #tree . selection . has ( item . id ) , item . children ) ;
425335 }
426336 }
427337
@@ -430,12 +340,12 @@ export default class CbxTree extends HTMLElement {
430340 * @param {CbxTreeItem } item
431341 */
432342 #syncAncestors( item ) {
433- if ( this . #tree. has ( item . id ) ) { // top-level item
343+ if ( this . #tree. tree . has ( item . id ) ) { // top-level item
434344 return ;
435345 }
436- const parentItem = this . #getItem ( item . id . slice ( 0 , item . id . lastIndexOf ( ':' ) ) ) ;
437- const state = this . #calcItemState( parentItem ) ;
438- this . #selection[ state === 'checked' ? 'add' : 'delete' ] ( parentItem . id ) ;
346+ const parentItem = this . #tree . getParentItem ( item . id ) ;
347+ const state = this . #tree . calcItemState ( parentItem ) ;
348+ this . #tree . selection [ state === 'checked' ? 'add' : 'delete' ] ( parentItem . id ) ;
439349 const checkbox = this . #shadowRoot. getElementById ( `cbx_${ parentItem . id } ` ) ;
440350 checkbox . checked = state === 'checked' ;
441351 checkbox . indeterminate = state === 'indeterminate' ;
@@ -453,8 +363,8 @@ export default class CbxTree extends HTMLElement {
453363 #toggleItemChecked( checkbox ) {
454364 const id = unprefixId ( checkbox . id ) ;
455365 const method = checkbox . checked ? 'add' : 'delete' ;
456- this . #selection[ method ] ( id ) ;
457- const item = this . #getItem( id ) ;
366+ this . #tree . selection [ method ] ( id ) ;
367+ const item = this . #tree . getItem ( id ) ;
458368 // Order of synchronisation matters (descendants first, then ancestors)
459369 this . #syncDescendants( item ) ;
460370 this . #syncAncestors( item ) ;
@@ -473,7 +383,7 @@ export default class CbxTree extends HTMLElement {
473383 if ( isExpanding ) {
474384 this . #requestSubtree( id ) ;
475385 }
476- const item = this . #getItem( id ) ;
386+ const item = this . #tree . getItem ( id ) ;
477387 item . collapsed = ! isExpanding ;
478388 this . #focusedLabel = itemElement . querySelector ( '[part="label"]' ) ;
479389 this . #refreshFormValue( ) ;
@@ -593,30 +503,14 @@ export default class CbxTree extends HTMLElement {
593503 const contentJSON = this . textContent . trim ( ) || '[]' ;
594504 try {
595505 const tree = JSON . parse ( contentJSON ) ;
596- assertRawTreeValid ( tree ) ;
506+ Tree . assertRawTreeValid ( tree ) ;
597507 return tree ;
598508 } catch {
599509 console . error ( new DOMException ( '<cbx-tree> contents must be a valid JSON array representation' , 'DataError' ) ) ;
600510 return [ ] ;
601511 }
602512 }
603513
604- /**
605- * Convert internal representation of a tree back to its raw format
606- * @param {CbxTreeMap } tree
607- * @returns {CbxRawTreeItem[] }
608- */
609- #toRaw( tree = this . #tree) {
610- return [ ...tree . values ( ) ] . map ( ( item ) => ( {
611- title : item . title ,
612- value : item . value ,
613- icon : item . icon ,
614- checked : this . #selection. has ( item . id ) ,
615- collapsed : item . collapsed === true ? true : undefined ,
616- children : item . children ? this . #toRaw( item . children ) : item . children ,
617- } ) ) ;
618- }
619-
620514
621515 // === Public interface ===
622516
@@ -625,15 +519,14 @@ export default class CbxTree extends HTMLElement {
625519 * @param {CbxRawTreeItem[] } treeData
626520 */
627521 setData ( treeData ) {
628- assertRawTreeValid ( treeData ) ;
629- this . #selection. clear ( ) ;
630- this . #tree = this . #buildTree( treeData ) ;
522+ Tree . assertRawTreeValid ( treeData ) ;
523+ this . #tree = new Tree ( treeData ) ;
631524 this . #render( ) ;
632525 this . #refreshFormValue( ) ;
633526 }
634527
635528 toJSON ( ) {
636- return this . #toRaw( ) ;
529+ return this . #tree . toRaw ( ) ;
637530 }
638531
639532 /**
@@ -644,7 +537,7 @@ export default class CbxTree extends HTMLElement {
644537 if ( checked === undefined ) {
645538 checked = ! ! this . #shadowRoot. querySelector ( '[part="checkbox"]:not(:checked)' ) ;
646539 }
647- this . #setAllChecked( checked , this . #tree) ;
540+ this . #setAllChecked( checked , this . #tree. tree ) ;
648541 this . #refreshFormValue( ) ;
649542 }
650543
@@ -663,7 +556,7 @@ export default class CbxTree extends HTMLElement {
663556 return ;
664557 }
665558 itemElement . ariaExpanded = ariaExpanded ;
666- const item = this . #getItem( unprefixId ( itemElement . id ) ) ;
559+ const item = this . #tree . getItem ( unprefixId ( itemElement . id ) ) ;
667560 item . collapsed = ! isExpanding ;
668561 } ) ;
669562 this . #refreshFormValue( ) ;
0 commit comments