license /**
 * Unique symbol for marking roots
 */ const selectionController = Symbol ( 'selection controller' ) ; /**
 * Set of checkable elements with added metadata
 */ export class SingleSelectionSet { selected : CheckableElement | null = null ; ordered : CheckableElement [ ] | null = null ; readonly set = new Set < CheckableElement > ( ) ; } /**
 * Element that is checkable consumed by
 * `SingleSelectionController` and `SingleSelectionSet`
 */ export type CheckableElement = HTMLElement & { name : string ; checked : boolean ; formElementTabIndex ? : number ; } ; /**
 * Controller that provides behavior similar to a native `<input type="radio">`
 * group.
 *
 * Behaviors:
 *
 * - Selection via key navigation (currently LTR is supported)
 * - Deselection of other grouped, checkable controls upon selection
 * - Grouping of checkable elements by name
 *   - Defaults grouping scope to host shadow root
 *   - Document-wide scoping enabled
 * - Land focus only on checked element. Focuses leading element when none
 *   checked.
 *
 * Intended Usage:
 *
 * ```ts
 * class MyElement extends HTMLElement {
 *   private selectionController: SingleSelectionController | null = null;
 *   name = "";
 *   global = false;
 *
 *   private _checked = false;
 *   set checked(checked: boolean) {
 *     const oldVal = this._checked;
 *     if (checked === oldVal) return;
 *
 *     this._checked = checked;
 *
 *     if (this.selectionController) {
 *       this.selectionController.update(this)
 *     }
 *   }
 *
 *   get checked() {
 *     return this._checked;
 *   }
 *
 *   connectedCallback() {
 *     this.selectionController = SelectionController.getController(this);
 *     this.selectionController.register(this);
 *     this.selectionController.update(this);
 *   }
 *
 *   disconnectedCallback() {
 *     this.selectionController!.unregister(this);
 *     this.selectionController = null;
 *   }
 * }
 * ```
 */ export class SingleSelectionController { private readonly sets : { [ name ] : SingleSelectionSet ; } = { } ; private focusedSet : SingleSelectionSet | null = null ; private mouseIsDown = false ; private updating = false ; param element static getController ( element ) { const useGlobal = ! ( 'global' in element ) || ( 'global' in element && element . global ) ; const root = useGlobal ? document as Document & { [ selectionController ] ? : SingleSelectionController ; } : ( element as Element ) . getRootNode ( ) as Node & { [ selectionController ] ? : SingleSelectionController ; } ; let controller = root [ selectionController ] ; if ( controller === undefined ) { controller = new SingleSelectionController ( root ) ; root [ selectionController ] = controller ; } return controller ; } constructor ( element ) { element . addEventListener ( 'keydown' , ( e ) => { this . keyDownHandler ( e as KeyboardEvent ) ; } ) ; element . addEventListener ( 'mousedown' , ( ) => { this . mousedownHandler ( ) ; } ) ; element . addEventListener ( 'mouseup' , ( ) => { this . mouseupHandler ( ) ; } ) ; } protected keyDownHandler ( e ) { const element = e . target as EventTarget | CheckableElement ; if ( ! ( 'checked' in element ) ) { return ; } if ( ! this . has ( element ) ) { return ; } if ( e . key == 'ArrowRight' || e . key == 'ArrowDown' ) { this . selectNext ( element ) ; } else if ( e . key == 'ArrowLeft' || e . key == 'ArrowUp' ) { this . selectPrevious ( element ) ; } } protected mousedownHandler ( ) { this . mouseIsDown = true ; } protected mouseupHandler ( ) { this . mouseIsDown = false ; } param element has ( element ) { const set = this . getSet ( element . name ) ; return set . set . has ( element ) ; } param element selectPrevious ( element ) { const order = this . getOrdered ( element ) ; const i = order . indexOf ( element ) ; const previous = order [ i - 1 ] || order [ order . length - 1 ] ; this . select ( previous ) ; return previous ; } param element selectNext ( element ) { const order = this . getOrdered ( element ) ; const i = order . indexOf ( element ) ; const next = order [ i + 1 ] || order [ 0 ] ; this . select ( next ) ; return next ; } select ( element ) { element . click ( ) ; } param element deprecated focus ( element ) { if ( this . mouseIsDown ) { return ; } const set = this . getSet ( element . name ) ; const currentFocusedSet = this . focusedSet ; this . focusedSet = set ; if ( currentFocusedSet != set && set . selected && set . selected != element ) { set . selected . focus ( ) ; } } return isAnySelected ( element ) { const set = this . getSet ( element . name ) ; for ( const e of set . set ) { if ( e . checked ) { return true ; } } return false ; } param element getOrdered ( element ) { const set = this . getSet ( element . name ) ; if ( ! set . ordered ) { set . ordered = Array . from ( set . set ) ; set . ordered . sort ( ( a , b ) => a . compareDocumentPosition ( b ) == Node . DOCUMENT_POSITION_PRECEDING ? 1 : 0 ) ; } return set . ordered ; } param name getSet ( name ) { if ( ! this . sets [ name ] ) { this . sets [ name ] = new SingleSelectionSet ( ) ; } return this . sets [ name ] ; } param element register ( element ) { const name = element . name || element . getAttribute ( 'name' ) || '' ; const set = this . getSet ( name ) ; set . set . add ( element ) ; set . ordered = null ; } param element unregister ( element ) { const set = this . getSet ( element . name ) ; set . set . delete ( element ) ; set . ordered = null ; if ( set . selected == element ) { set . selected = null ; } } param element update ( element ) { if ( this . updating ) { return ; } this . updating = true ; const set = this . getSet ( element . name ) ; if ( element . checked ) { for ( const e of set . set ) { if ( e == element ) { continue ; } e . checked = false ; } set . selected = element ; } if ( this . isAnySelected ( element ) ) { for ( const e of set . set ) { if ( e . formElementTabIndex === undefined ) { break ; } e . formElementTabIndex = e . checked ? 0 : - 1 ; } } this . updating = false ; } }