1- const bound = new Set < string > ( )
1+ const controllers = new Set < string > ( )
2+
23/*
34 * Bind `[data-action]` elements from the DOM to their actions.
45 *
56 */
67export function bind ( controller : HTMLElement ) : void {
7- const tag = controller . tagName . toLowerCase ( )
8- bound . add ( tag )
9- const actionAttributeMatcher = `[data-action*=":${ tag } #"]`
10-
11- for ( const el of controller . querySelectorAll ( actionAttributeMatcher ) ) {
12- // Ignore nested elements
13- if ( el . closest ( tag ) !== controller ) continue
14- bindActionsToController ( controller , el )
15- }
16-
17- // Also bind the controller to itself
18- if ( controller . matches ( actionAttributeMatcher ) ) {
19- bindActionsToController ( controller , controller )
20- }
21- }
22-
23- // Match the pattern of `eventName:constructor#method`.
24- function * getActions ( el : Element ) : Generator < [ string , string , string ] > {
25- for ( const binding of ( el . getAttribute ( 'data-action' ) || '' ) . split ( ' ' ) ) {
26- const [ rest , methodName ] = binding . split ( '#' )
27- if ( ! methodName ) continue
28-
29- // eventName may contain `:` so account for that
30- // by splitting by the last instance of `:`
31- const colonIndex = rest . lastIndexOf ( ':' )
32- if ( colonIndex < 0 ) continue
33-
34- yield [ rest . slice ( 0 , colonIndex ) , rest . slice ( colonIndex + 1 ) , methodName ]
35- }
36- }
37-
38- function bindActionToController ( controller : HTMLElement , el : Element , methodName : string , eventName : string ) {
39- // Check the `method` is present on the prototype
40- const methodDescriptor =
41- Object . getOwnPropertyDescriptor ( controller , methodName ) ||
42- Object . getOwnPropertyDescriptor ( Object . getPrototypeOf ( controller ) , methodName )
43- if ( methodDescriptor && typeof methodDescriptor . value == 'function' ) {
44- el . addEventListener ( eventName , ( event : Event ) => {
45- methodDescriptor . value . call ( controller , event )
46- } )
47- }
48- }
49-
50- // Bind the data-action attribute of a single element to the controller
51- function bindActionsToController ( controller : HTMLElement , el : Element ) {
52- const tag = controller . tagName . toLowerCase ( )
53-
54- for ( const [ eventName , tagName , methodName ] of getActions ( el ) ) {
55- if ( tagName === tag ) {
56- bindActionToController ( controller , el , methodName , eventName )
57- }
58- }
59- }
60-
61- interface Subscription {
62- closed : boolean
63- unsubscribe ( ) : void
8+ controllers . add ( controller . tagName . toLowerCase ( ) )
9+ findElementsToBind ( controller )
6410}
6511
6612/**
@@ -70,26 +16,22 @@ interface Subscription {
7016 * This returns a Subscription object which you can call `unsubscribe()` on to
7117 * stop further live updates.
7218 */
73- export function listenForBind ( el : Node = document , batchSize = 30 ) : Subscription {
19+ export function listenForBind ( el : Node = document ) : Subscription {
7420 let closed = false
75-
7621 const observer = new MutationObserver ( mutations => {
77- const queue = new Set < Element > ( )
7822 for ( const mutation of mutations ) {
79- if ( mutation . type === 'childList' && mutation . addedNodes . length ) {
23+ if ( mutation . type === 'attributes' && mutation . target instanceof Element ) {
24+ bindActions ( mutation . target )
25+ } else if ( mutation . type === 'childList' && mutation . addedNodes . length ) {
8026 for ( const node of mutation . addedNodes ) {
81- if ( ! ( node instanceof Element ) ) continue
82- if ( node . hasAttribute ( 'data-action' ) ) {
83- queue . add ( node )
27+ if ( node instanceof Element ) {
28+ findElementsToBind ( node )
8429 }
8530 }
8631 }
8732 }
88- if ( queue . size ) requestAnimationFrame ( ( ) => processQueue ( queue , batchSize ) )
8933 } )
90-
91- observer . observe ( el , { childList : true , subtree : true } )
92-
34+ observer . observe ( el , { childList : true , subtree : true , attributes : true , attributeFilter : [ 'data-action' ] } )
9335 return {
9436 get closed ( ) {
9537 return closed
@@ -101,22 +43,75 @@ export function listenForBind(el: Node = document, batchSize = 30): Subscription
10143 }
10244}
10345
104- function processQueue ( queue : Set < Element > , batchSize : number ) {
105- let counter = batchSize
106- for ( const el of queue ) {
107- for ( const [ eventName , controllerTag , methodName ] of getActions ( el ) ) {
108- if ( ! bound . has ( controllerTag ) ) continue
109- const controller = el . closest ( controllerTag )
110- if ( ! ( controller instanceof HTMLElement ) ) continue
46+ interface Subscription {
47+ closed : boolean
48+ unsubscribe ( ) : void
49+ }
11150
112- bindActionToController ( controller , el , methodName , eventName )
51+ function findElementsToBind ( root : Element ) {
52+ for ( const el of root . querySelectorAll ( '[data-action]' ) ) {
53+ bindActions ( el )
54+ }
55+ // Also bind the controller to itself
56+ if ( root . hasAttribute ( 'data-action' ) ) {
57+ bindActions ( root )
58+ }
59+ }
60+
61+ function getActionEventName ( action : string ) : string {
62+ return action . slice ( 0 , action . lastIndexOf ( ':' ) )
63+ }
64+
65+ function getActionControllerName ( action : string ) : string {
66+ return action . slice ( action . lastIndexOf ( ':' ) + 1 , action . lastIndexOf ( '#' ) )
67+ }
68+
69+ function getActionMethodName ( action : string ) : string {
70+ return action . slice ( action . lastIndexOf ( '#' ) + 1 )
71+ }
72+
73+ // ControllerEventHandler is a global event handler that dispatches events to
74+ // controllers. We use a global event handler over bindings functions because
75+ // this is far more performant; creating functions for each `addEventListener`
76+ // would be very costly for CPU performance (and memory), while registering a
77+ // single handler for every event keeps things relatively performant.
78+ const ControllerEventHandler = {
79+ handleEvent ( event : Event ) {
80+ const el = event . currentTarget
81+ if ( ! ( el instanceof Element ) ) return
82+ for ( const action of ( el . getAttribute ( 'data-action' ) || '' ) . split ( ' ' ) ) {
83+ // We want to dispatch this event, only to the subscribers; we filter by
84+ // event.type to find which actions should fire
85+ const eventType = getActionEventName ( action )
86+ if ( event . type !== eventType ) continue
87+ // We need to find the closest controller to dispatch the event to.
88+ const tagName = getActionControllerName ( action )
89+ // The controller should be "well known" in that `bind()` should have
90+ // been called on it.
91+ if ( ! controllers . has ( tagName ) ) continue
92+ const controller = el . closest ( tagName ) as Element & Record < string , ( ev : Event ) => unknown >
93+ if ( ! controller ) continue
94+ // Finally we need to get the right method to call on the controller.
95+ // The method also needs to exist!
96+ const method = getActionMethodName ( action )
97+ if ( typeof controller [ method ] === 'function' ) {
98+ controller [ method ] ( event )
99+ }
113100 }
114- queue . delete ( el )
101+ }
102+ }
115103
116- counter -= 1
117- if ( counter === 0 ) break
104+ const bindings = new WeakMap < Element , Set < string > > ( )
105+ function bindActions ( el : Element ) {
106+ if ( ! bindings . has ( el ) ) {
107+ bindings . set ( el , new Set ( ) )
118108 }
119- if ( queue . size !== 0 ) {
120- requestAnimationFrame ( ( ) => processQueue ( queue , batchSize ) )
109+ const elementBindings = bindings . get ( el ) !
110+ for ( const action of ( el . getAttribute ( 'data-action' ) || '' ) . split ( ' ' ) ) {
111+ const event = getActionEventName ( action )
112+ if ( ! elementBindings . has ( event ) ) {
113+ el . addEventListener ( event , ControllerEventHandler )
114+ }
115+ elementBindings . add ( event )
121116 }
122117}
0 commit comments