2021-01-22 02:08:55 +00:00
|
|
|
/**
|
2021-04-21 20:45:52 +00:00
|
|
|
* Based on the core checkboxHack,
|
|
|
|
* backported because some features are not avaliable in 1.35,
|
|
|
|
* see https://github.com/wikimedia/mediawiki/blob/master/resources/src/mediawiki.page.ready/checkboxHack.js
|
2021-01-22 02:08:55 +00:00
|
|
|
*
|
2022-05-12 01:32:36 +00:00
|
|
|
* TODO: Use core module when we move to 1.39
|
2021-04-21 20:45:52 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Revise the button's `aria-expanded` state to match the checked state.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @return {void}
|
|
|
|
* @ignore
|
2021-01-22 02:08:55 +00:00
|
|
|
*/
|
2022-05-12 01:32:36 +00:00
|
|
|
function updateAriaExpanded( checkbox ) {
|
|
|
|
checkbox.setAttribute( 'aria-expanded', checkbox.checked.toString() );
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
2021-02-03 20:00:43 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
/**
|
|
|
|
* Set the checked state and fire the 'input' event.
|
2022-05-12 01:32:36 +00:00
|
|
|
* Programmatic changes to checkbox.checked do not trigger an input or change event.
|
|
|
|
* The input event in turn will call updateAriaExpanded().
|
|
|
|
*
|
|
|
|
* setCheckedState() is called when a user event on some element other than the checkbox
|
|
|
|
* should result in changing the checkbox state.
|
|
|
|
*
|
|
|
|
* Per https://html.spec.whatwg.org/multipage/indices.html#event-input
|
|
|
|
* Input event is fired at controls when the user changes the value.
|
|
|
|
* Per https://html.spec.whatwg.org/multipage/input.html#checkbox-state-(type=checkbox):event-input
|
|
|
|
* Fire an event named input at the element with the bubbles attribute initialized to true.
|
|
|
|
*
|
|
|
|
* https://html.spec.whatwg.org/multipage/indices.html#event-change
|
|
|
|
* For completeness the 'change' event should be fired too,
|
|
|
|
* however we make no use of the 'change' event,
|
|
|
|
* nor expect it to be used, thus firing it
|
|
|
|
* would be unnecessary load.
|
2021-04-21 20:45:52 +00:00
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {boolean} checked
|
|
|
|
* @return {void}
|
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function setCheckedState( checkbox, checked ) {
|
2022-05-12 01:32:36 +00:00
|
|
|
/** @type {Event} @ignore */
|
|
|
|
let e;
|
2021-04-21 20:45:52 +00:00
|
|
|
checkbox.checked = checked;
|
2022-05-12 01:32:36 +00:00
|
|
|
// Chrome and Firefox sends the builtin Event with .bubbles == true and .composed == true.
|
|
|
|
if ( typeof Event === 'function' ) {
|
|
|
|
e = new Event( 'input', { bubbles: true, composed: true } );
|
|
|
|
} else {
|
|
|
|
// IE 9-11, FF 6-10, Chrome 9-14, Safari 5.1, Opera 11.5, Android 3-4.3
|
|
|
|
e = document.createEvent( 'CustomEvent' );
|
|
|
|
if ( !e ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.initCustomEvent( 'input', true /* canBubble */, false, false );
|
|
|
|
}
|
|
|
|
checkbox.dispatchEvent( e );
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
2021-01-29 18:50:20 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
/**
|
|
|
|
* Returns true if the Event's target is an inclusive descendant of any the checkbox hack's
|
|
|
|
* constituents (checkbox, button, or target), and false otherwise.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {HTMLElement} button
|
|
|
|
* @param {Node} target
|
|
|
|
* @param {Event} event
|
|
|
|
* @return {boolean}
|
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function containsEventTarget( checkbox, button, target, event ) {
|
|
|
|
return event.target instanceof Node && (
|
|
|
|
checkbox.contains( event.target ) ||
|
2022-05-12 01:32:36 +00:00
|
|
|
button.contains( event.target ) ||
|
|
|
|
target.contains( event.target )
|
2021-04-21 20:45:52 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Dismiss the target when event is outside the checkbox, button, and target.
|
|
|
|
* In simple terms this closes the target (menu, typically) when clicking somewhere else.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {HTMLElement} button
|
|
|
|
* @param {Node} target
|
|
|
|
* @param {Event} event
|
|
|
|
* @return {void}
|
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function dismissIfExternalEventTarget( checkbox, button, target, event ) {
|
|
|
|
if ( checkbox.checked && !containsEventTarget( checkbox, button, target, event ) ) {
|
|
|
|
setCheckedState( checkbox, false );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Update the `aria-expanded` attribute based on checkbox state (target visibility) changes.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
2022-05-12 01:32:36 +00:00
|
|
|
function bindUpdateAriaExpandedOnInput( checkbox ) {
|
|
|
|
const listener = updateAriaExpanded.bind( undefined, checkbox );
|
2021-04-21 20:45:52 +00:00
|
|
|
// Whenever the checkbox state changes, update the `aria-expanded` state.
|
|
|
|
checkbox.addEventListener( 'input', listener );
|
2022-05-12 01:32:36 +00:00
|
|
|
|
|
|
|
return function () {
|
|
|
|
checkbox.removeEventListener( 'input', listener );
|
|
|
|
};
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Manually change the checkbox state to avoid a focus change when using a pointing device.
|
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {HTMLElement} button
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function bindToggleOnClick( checkbox, button ) {
|
2022-05-12 01:32:36 +00:00
|
|
|
function listener( event ) {
|
2021-04-21 20:45:52 +00:00
|
|
|
// Do not allow the browser to handle the checkbox. Instead, manually toggle it which does
|
|
|
|
// not alter focus.
|
|
|
|
event.preventDefault();
|
|
|
|
setCheckedState( checkbox, !checkbox.checked );
|
2022-05-12 01:32:36 +00:00
|
|
|
}
|
2021-04-21 20:45:52 +00:00
|
|
|
button.addEventListener( 'click', listener, true );
|
2022-05-12 01:32:36 +00:00
|
|
|
|
|
|
|
return function () {
|
|
|
|
button.removeEventListener( 'click', listener, true );
|
|
|
|
};
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-05-12 01:32:36 +00:00
|
|
|
* Manually change the checkbox state when the button is focused and Enter is pressed.
|
2021-04-21 20:45:52 +00:00
|
|
|
*
|
|
|
|
* @param {HTMLInputElement} checkbox
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
2022-05-12 01:32:36 +00:00
|
|
|
function bindToggleOnEnter( checkbox ) {
|
|
|
|
function onKeyup( /** @type {KeyboardEvent} @ignore */ event ) {
|
|
|
|
// Only handle ENTER.
|
|
|
|
if ( event.key !== 'Enter' ) {
|
2021-04-21 20:45:52 +00:00
|
|
|
return;
|
2021-02-03 20:00:43 +00:00
|
|
|
}
|
2022-05-12 01:32:36 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
setCheckedState( checkbox, !checkbox.checked );
|
2022-05-12 01:32:36 +00:00
|
|
|
}
|
2021-02-03 20:00:43 +00:00
|
|
|
|
2022-05-12 01:32:36 +00:00
|
|
|
checkbox.addEventListener( 'keyup', onKeyup );
|
|
|
|
|
|
|
|
return function () {
|
|
|
|
checkbox.removeEventListener( 'keyup', onKeyup );
|
|
|
|
};
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
2021-02-03 20:00:43 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
/**
|
|
|
|
* Dismiss the target when clicking elsewhere and update the `aria-expanded` attribute based on
|
|
|
|
* checkbox state (target visibility).
|
|
|
|
*
|
|
|
|
* @param {Window} window
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {HTMLElement} button
|
|
|
|
* @param {Node} target
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function bindDismissOnClickOutside( window, checkbox, button, target ) {
|
|
|
|
const listener = dismissIfExternalEventTarget.bind( undefined, checkbox, button, target );
|
|
|
|
window.addEventListener( 'click', listener, true );
|
2022-05-12 01:32:36 +00:00
|
|
|
|
|
|
|
return function () {
|
|
|
|
window.removeEventListener( 'click', listener, true );
|
|
|
|
};
|
2021-01-22 02:08:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2021-04-21 20:45:52 +00:00
|
|
|
* Dismiss the target when focusing elsewhere and update the `aria-expanded` attribute based on
|
|
|
|
* checkbox state (target visibility).
|
2021-04-21 17:44:28 +00:00
|
|
|
*
|
2021-04-21 20:45:52 +00:00
|
|
|
* @param {Window} window
|
|
|
|
* @param {HTMLInputElement} checkbox
|
|
|
|
* @param {HTMLElement} button
|
|
|
|
* @param {Node} target
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
2021-01-22 02:08:55 +00:00
|
|
|
*/
|
2021-04-21 20:45:52 +00:00
|
|
|
function bindDismissOnFocusLoss( window, checkbox, button, target ) {
|
|
|
|
// If focus is given to any element outside the target, dismiss the target. Setting a focusout
|
|
|
|
// listener on the target would be preferable, but this interferes with the click listener.
|
|
|
|
const listener = dismissIfExternalEventTarget.bind( undefined, checkbox, button, target );
|
|
|
|
window.addEventListener( 'focusin', listener, true );
|
2022-05-12 01:32:36 +00:00
|
|
|
|
|
|
|
return function () {
|
|
|
|
window.removeEventListener( 'focusin', listener, true );
|
|
|
|
};
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
2021-03-08 19:32:29 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
/**
|
|
|
|
* Dismiss the target when ESCAPE is pressed.
|
2022-05-12 01:32:36 +00:00
|
|
|
* NOTE: This is not a part of the core checkboxHack API
|
2021-04-21 20:45:52 +00:00
|
|
|
*
|
|
|
|
* @param {Window} window
|
|
|
|
* @param {HTMLInputElement} checkbox
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function bindDismissOnEscape( window, checkbox ) {
|
2022-05-12 01:32:36 +00:00
|
|
|
const onKeyup = ( /** @type {KeyboardEvent} */ event ) => {
|
2021-04-21 20:45:52 +00:00
|
|
|
// Only handle ESCAPE
|
|
|
|
if ( event.key !== 'Escape' ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setCheckedState( checkbox, false );
|
2021-04-21 17:44:28 +00:00
|
|
|
};
|
2021-03-08 19:32:29 +00:00
|
|
|
|
2022-05-12 01:32:36 +00:00
|
|
|
window.addEventListener( 'keyup', onKeyup, true );
|
|
|
|
return function () {
|
|
|
|
window.removeEventListener( 'keyup', onKeyup );
|
|
|
|
};
|
2021-04-21 20:45:52 +00:00
|
|
|
}
|
2021-04-21 17:44:28 +00:00
|
|
|
|
2021-04-21 20:45:52 +00:00
|
|
|
/**
|
|
|
|
* Dismiss the target when clicking or focusing elsewhere and update the `aria-expanded` attribute
|
|
|
|
* based on checkbox state (target visibility) changes made by **the user.** When tapping the button
|
|
|
|
* itself, clear the focus outline.
|
|
|
|
*
|
|
|
|
* This function calls the other bind* functions and is the only expected interaction for most use
|
|
|
|
* cases. It's constituents are provided distinctly for the other use cases.
|
|
|
|
*
|
|
|
|
* @param {Window} window
|
|
|
|
* @param {HTMLInputElement} checkbox The underlying hidden checkbox that controls target
|
|
|
|
* visibility.
|
|
|
|
* @param {HTMLElement} button The visible label icon associated with the checkbox. This button
|
|
|
|
* toggles the state of the underlying checkbox.
|
|
|
|
* @param {Node} target The Node to toggle visibility of based on checkbox state.
|
2022-05-12 01:32:36 +00:00
|
|
|
* @return {function(): void} Cleanup function that removes the added event listeners.
|
2021-04-21 20:45:52 +00:00
|
|
|
* @ignore
|
|
|
|
*/
|
|
|
|
function bind( window, checkbox, button, target ) {
|
2022-05-12 01:32:36 +00:00
|
|
|
const cleanups = [
|
|
|
|
bindUpdateAriaExpandedOnInput( checkbox ),
|
|
|
|
bindToggleOnClick( checkbox, button ),
|
|
|
|
bindToggleOnEnter( checkbox ),
|
|
|
|
bindDismissOnClickOutside( window, checkbox, button, target ),
|
|
|
|
bindDismissOnFocusLoss( window, checkbox, button, target ),
|
|
|
|
bindDismissOnEscape( window, checkbox )
|
|
|
|
];
|
2021-04-21 17:44:28 +00:00
|
|
|
|
2022-05-12 01:32:36 +00:00
|
|
|
return function () {
|
|
|
|
cleanups.forEach( function ( cleanup ) {
|
|
|
|
cleanup();
|
|
|
|
} );
|
2021-04-21 20:45:52 +00:00
|
|
|
};
|
2021-01-22 02:08:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = {
|
2021-04-21 20:45:52 +00:00
|
|
|
updateAriaExpanded: updateAriaExpanded,
|
|
|
|
bindUpdateAriaExpandedOnInput: bindUpdateAriaExpandedOnInput,
|
|
|
|
bindToggleOnClick: bindToggleOnClick,
|
2022-05-12 01:32:36 +00:00
|
|
|
bindToggleOnEnter: bindToggleOnEnter,
|
2021-04-21 20:45:52 +00:00
|
|
|
bindDismissOnClickOutside: bindDismissOnClickOutside,
|
|
|
|
bindDismissOnFocusLoss: bindDismissOnFocusLoss,
|
2022-05-12 01:32:36 +00:00
|
|
|
bindDismissOnEscape: bindDismissOnEscape,
|
|
|
|
bind: bind
|
2021-01-22 02:08:55 +00:00
|
|
|
};
|