Merge "SpecialBlock [Vue]: add NamespacesField and PagesField components"
[mediawiki.git] / resources / src / jquery / jquery.confirmable.js
blobce76e0a18fec0be7a78d08c6beb659b7f7868806
1 /**
2  * Enable inline confirmation for clickable elements.
3  *
4  * @module jquery.confirmable
5  * @author Bartosz Dziewoński
6  * @license MIT
7  */
8 ( function () {
9         const identity = ( data ) => data;
11         /**
12          * Enable inline confirmation for given clickable element (like `<a />` or `<button />`).
13          *
14          * An additional inline confirmation step being shown before the default action is carried out on
15          * click.
16          *
17          * Calling `.confirmable( { handler: function () { … } } )` will fire the handler only after the
18          * confirmation step.
19          *
20          * The element will have the `jquery-confirmable-element` class added to it when it's clicked for
21          * the first time, which has `white-space: nowrap;` and `display: inline-block;` defined in CSS.
22          * If the computed values for the element are different when you make it confirmable, you might
23          * encounter unexpected behavior.
24          *
25          * To use this {@link jQuery} plugin, load the `jquery.confirmable` module with {@link mw.loader}.
26          *
27          * @example
28          * mw.loader.using( 'jquery.confirmable' ).then( () => {
29          *       $( 'button' ).confirmable();
30          * } );
31          * @memberof module:jquery.confirmable
32          * @method
33          * @param {Object} [options]
34          * @param {string} [options.events='click'] Events to hook to.
35          * @param {Function} [options.wrapperCallback] Callback to fire when preparing confirmable
36          *     interface. Receives the interface jQuery object as the only parameter.
37          * @param {Function} [options.buttonCallback] Callback to fire when preparing confirmable buttons.
38          *     It is fired separately for the 'Yes' and 'No' button. Receives the button jQuery object as
39          *     the first parameter and 'yes' or 'no' as the second.
40          * @param {Function} [options.handler] Callback to fire when the action is confirmed (user clicks
41          *     the 'Yes' button).
42          * @param {string} [options.delegate] Optional selector used for jQuery event delegation
43          * @param {string} [options.i18n] Text to use for interface elements.
44          * @param {string} [options.i18n.space=' '] Word separator to place between the three text messages.
45          * @param {string} [options.i18n.confirm='Are you sure?'] Text to use for the confirmation question.
46          * @param {string} [options.i18n.yes='Yes'] Text to use for the 'Yes' button.
47          * @param {string} [options.i18n.no='No'] Text to use for the 'No' button.
48          * @param {string} [options.i18n.yesTitle] Optional title text to use for the 'Yes' button.
49          * @param {string} [options.i18n.noTitle] Optional title text to use for the 'No' button.
50          * @return {jQuery}
51          */
52         $.fn.confirmable = function ( options ) {
53                 options = $.extend( true, {}, $.fn.confirmable.defaultOptions, options || {} );
55                 if ( options.delegate === null ) {
56                         return this.on( options.events, ( e ) => {
57                                 $.fn.confirmable.handler( e, options );
58                         } );
59                 }
61                 return this.on( options.events, options.delegate, ( e ) => {
62                         $.fn.confirmable.handler( e, options );
63                 } );
64         };
66         $.fn.confirmable.handler = function ( event, options ) {
67                 const $element = $( event.target );
69                 if ( $element.data( 'jquery-confirmable-button' ) ) {
70                         // We're running on a clone of this element that represents the 'Yes' or 'No' button.
71                         // (This should never happen for the 'No' case unless calling code does bad things.)
72                         return;
73                 }
75                 // Only prevent native event handling. Stopping other JavaScript event handlers
76                 // is impossible because they might have already run (we have no control over the order).
77                 event.preventDefault();
79                 const rtl = $element.css( 'direction' ) === 'rtl';
80                 let positionOffscreen, positionRestore, sideMargin, elementSideMargin;
81                 if ( rtl ) {
82                         positionOffscreen = { position: 'absolute', right: '-9999px' };
83                         positionRestore = { position: '', right: '' };
84                         sideMargin = 'marginRight';
85                         elementSideMargin = parseInt( $element.css( 'margin-right' ) );
86                 } else {
87                         positionOffscreen = { position: 'absolute', left: '-9999px' };
88                         positionRestore = { position: '', left: '' };
89                         sideMargin = 'marginLeft';
90                         elementSideMargin = parseInt( $element.css( 'margin-left' ) );
91                 }
93                 $element.addClass( 'hidden' );
94                 let $wrapper, $interface, interfaceWidth, elementWidth, elementPadding;
95                 // eslint-disable-next-line no-jquery/no-class-state
96                 if ( $element.hasClass( 'jquery-confirmable-element' ) ) {
97                         $wrapper = $element.closest( '.jquery-confirmable-wrapper' );
98                         $interface = $wrapper.find( '.jquery-confirmable-interface' );
100                         interfaceWidth = $interface.data( 'jquery-confirmable-width' );
101                         elementWidth = $element.data( 'jquery-confirmable-width' );
102                         elementPadding = $element.data( 'jquery-confirmable-padding' );
103                         // Restore visibility to interface text if it is opened again after being cancelled.
104                         const $existingText = $interface.find( '.jquery-confirmable-text' );
105                         $existingText.removeClass( 'hidden' );
106                 } else {
107                         const $elementClone = $element.clone( true );
108                         $element.addClass( 'jquery-confirmable-element' );
110                         elementWidth = $element.width();
111                         elementPadding = parseInt( $element.css( 'padding-left' ) ) + parseInt( $element.css( 'padding-right' ) );
112                         $element.data( 'jquery-confirmable-width', elementWidth );
113                         $element.data( 'jquery-confirmable-padding', elementPadding );
115                         $wrapper = $( '<span>' )
116                                 .addClass( 'jquery-confirmable-wrapper' );
117                         $element.wrap( $wrapper );
119                         // Build the mini-dialog
120                         const $text = $( '<span>' )
121                                 .addClass( 'jquery-confirmable-text' )
122                                 .text( options.i18n.confirm );
124                         // Clone original element along with event handlers to easily replicate its behavior.
125                         // We could fiddle with .trigger() etc., but that is troublesome especially since
126                         // Safari doesn't implement .click() on <a> links and jQuery follows suit.
127                         let $buttonYes = $elementClone.clone( true )
128                                 .addClass( 'jquery-confirmable-button jquery-confirmable-button-yes' )
129                                 .removeClass( 'hidden' )
130                                 .data( 'jquery-confirmable-button', true )
131                                 .text( options.i18n.yes );
132                         if ( options.handler ) {
133                                 $buttonYes.on( options.events, options.handler );
134                         }
135                         if ( options.i18n.yesTitle ) {
136                                 $buttonYes.attr( 'title', options.i18n.yesTitle );
137                         }
138                         $buttonYes = options.buttonCallback( $buttonYes, 'yes' );
140                         // Clone it without any events and prevent default action to represent the 'No' button.
141                         let $buttonNo = $elementClone.clone( false )
142                                 .addClass( 'jquery-confirmable-button jquery-confirmable-button-no' )
143                                 .removeClass( 'hidden' )
144                                 .data( 'jquery-confirmable-button', true )
145                                 .text( options.i18n.no )
146                                 .on( options.events, ( e ) => {
147                                         $element
148                                                 .css( sideMargin, elementSideMargin )
149                                                 .removeClass( 'hidden' );
150                                         $interface.css( 'width', 0 );
151                                         e.preventDefault();
152                                 } );
153                         if ( options.i18n.noTitle ) {
154                                 $buttonNo.attr( 'title', options.i18n.noTitle );
155                         } else {
156                                 $buttonNo.removeAttr( 'title' );
157                         }
158                         $buttonNo = options.buttonCallback( $buttonNo, 'no' );
160                         // Prevent memory leaks
161                         $elementClone.remove();
163                         $interface = $( '<span>' )
164                                 .addClass( 'jquery-confirmable-interface' )
165                                 .append( $text, options.i18n.space, $buttonYes, options.i18n.space, $buttonNo );
166                         $interface = options.wrapperCallback( $interface );
168                         // Render offscreen to measure real width
169                         $interface.css( positionOffscreen );
170                         // Insert it in the correct place while we're at it
171                         $element.after( $interface );
172                         interfaceWidth = $interface.width();
173                         $interface
174                                 .data( 'jquery-confirmable-width', interfaceWidth )
175                                 .css( positionRestore )
176                                 // Hide to animate the transition later
177                                 .css( 'width', 0 );
178                 }
180                 // Hide element, show interface. This triggers both transitions.
181                 // In a timeout to trigger the 'width' transition.
182                 setTimeout( () => {
183                         $element.css( sideMargin, -elementWidth - elementPadding );
184                         $interface
185                                 .css( 'width', interfaceWidth )
186                                 .css( sideMargin, elementSideMargin );
187                 }, 1 );
188         };
190         /**
191          * Default options. Overridable primarily for internationalisation handling.
192          *
193          * @property {Object} defaultOptions
194          */
195         $.fn.confirmable.defaultOptions = {
196                 events: 'click',
197                 wrapperCallback: identity,
198                 buttonCallback: identity,
199                 handler: null,
200                 delegate: null,
201                 i18n: {
202                         space: ' ',
203                         confirm: 'Are you sure?',
204                         yes: 'Yes',
205                         no: 'No',
206                         yesTitle: undefined,
207                         noTitle: undefined
208                 }
209         };
210 }() );