mediawiki-skins-Citizen/resources/skins.citizen.scripts/checkboxHack.js

278 lines
9.1 KiB
JavaScript
Raw Normal View History

2021-01-22 02:08:55 +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
*/
/**
* Checkbox hack listener state.
2021-01-22 02:08:55 +00:00
*
* @class {Object} CheckboxHackListeners
* @property {Function} [onUpdateAriaExpandedOnInput]
* @property {Function} [onToggleOnClick]
* @property {Function} [onToggleOnSpaceEnter]
* @property {Function} [onDismissOnClickOutside]
* @property {Function} [onDismissOnFocusLoss]
* @ignore
*/
/**
* Revise the button's `aria-expanded` state to match the checked state.
*
* @param {HTMLInputElement} checkbox
* @param {HTMLElement} button
* @return {void}
* @ignore
2021-01-22 02:08:55 +00:00
*/
function updateAriaExpanded( checkbox, button ) {
button.setAttribute( 'aria-expanded', checkbox.checked.toString() );
}
/**
* Set the checked state and fire the 'input' event.
*
* @param {HTMLInputElement} checkbox
* @param {boolean} checked
* @return {void}
* @ignore
*/
function setCheckedState( checkbox, checked ) {
// Chrome and Firefox sends the builtin Event with .bubbles == true and .composed == true.
/** @type {Event} */
const event = new Event( 'input', { bubbles: true, composed: true } );
checkbox.checked = checked;
checkbox.dispatchEvent( event );
}
2021-01-29 18:50:20 +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 ) ||
button.contains( event.target ) ||
target.contains( event.target )
);
}
/**
* 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
* @param {HTMLElement} button
* @return {CheckboxHackListeners}
* @ignore
*/
function bindUpdateAriaExpandedOnInput( checkbox, button ) {
const listener = updateAriaExpanded.bind( undefined, checkbox, button );
// Whenever the checkbox state changes, update the `aria-expanded` state.
checkbox.addEventListener( 'input', listener );
return { onUpdateAriaExpandedOnInput: listener };
}
/**
* Manually change the checkbox state to avoid a focus change when using a pointing device.
*
* @param {HTMLInputElement} checkbox
* @param {HTMLElement} button
* @return {CheckboxHackListeners}
* @ignore
*/
function bindToggleOnClick( checkbox, button ) {
const listener = ( event ) => {
// Do not allow the browser to handle the checkbox. Instead, manually toggle it which does
// not alter focus.
event.preventDefault();
setCheckedState( checkbox, !checkbox.checked );
};
button.addEventListener( 'click', listener, true );
return { onToggleOnClick: listener };
}
/**
* Manually change the checkbox state when the button is focused and SPACE is pressed.
*
* @param {HTMLInputElement} checkbox
* @param {HTMLElement} button
* @return {CheckboxHackListeners}
* @ignore
*/
function bindToggleOnSpaceEnter( checkbox, button ) {
const onToggleOnSpaceEnter = ( /** @type {KeyboardEvent} */ event ) => {
// Only handle SPACE and ENTER.
if ( event.key !== ' ' && event.key !== 'Enter' ) {
return;
}
event.preventDefault();
setCheckedState( checkbox, !checkbox.checked );
};
button.addEventListener( 'keydown', onToggleOnSpaceEnter, true );
return { onToggleOnSpaceEnter: onToggleOnSpaceEnter };
}
/**
* 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
* @return {CheckboxHackListeners}
* @ignore
*/
function bindDismissOnClickOutside( window, checkbox, button, target ) {
const listener = dismissIfExternalEventTarget.bind( undefined, checkbox, button, target );
window.addEventListener( 'click', listener, true );
return { onDismissOnClickOutside: listener };
2021-01-22 02:08:55 +00:00
}
/**
* Dismiss the target when focusing 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
* @return {CheckboxHackListeners}
* @ignore
2021-01-22 02:08:55 +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 );
return { onDismissOnFocusLoss: listener };
}
/**
* Dismiss the target when ESCAPE is pressed.
*
* @param {Window} window
* @param {HTMLInputElement} checkbox
* @return {CheckboxHackListeners}
* @ignore
*/
function bindDismissOnEscape( window, checkbox ) {
const onDismissOnEscape = ( /** @type {KeyboardEvent} */ event ) => {
// Only handle ESCAPE
if ( event.key !== 'Escape' ) {
return;
}
setCheckedState( checkbox, false );
};
window.addEventListener( 'keydown', onDismissOnEscape, true );
return { onDismissOnEscape: onDismissOnEscape };
}
/**
* 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.
* @return {CheckboxHackListeners}
* @ignore
*/
function bind( window, checkbox, button, target ) {
const spaceHandlers = bindToggleOnSpaceEnter( checkbox, button );
// ES6: return Object.assign( bindToggleOnSpaceEnter( checkbox, button ), ... );
// https://caniuse.com/#feat=mdn-javascript_builtins_object_assign
/* eslint-disable max-len */
return {
onUpdateAriaExpandedOnInput: bindUpdateAriaExpandedOnInput( checkbox ).onUpdateAriaExpandedOnInput,
onToggleOnClick: bindToggleOnClick( checkbox, button ).onToggleOnClick,
onToggleOnSpaceEnter: spaceHandlers.onToggleOnSpaceEnter,
onDismissOnClickOutside: bindDismissOnClickOutside( window, checkbox, button, target ).onDismissOnClickOutside,
onDismissOnFocusLoss: bindDismissOnFocusLoss( window, checkbox, button, target ).onDismissOnFocusLoss,
onDismissOnEscape: bindDismissOnEscape( window, checkbox ).onDismissOnEscape
};
/* eslint-enable max-len */
}
/**
* Free all set listeners.
*
* @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 {CheckboxHackListeners} listeners
* @return {void}
* @ignore
*/
function unbind( window, checkbox, button, listeners ) {
if ( listeners.onDismissOnFocusLoss ) {
window.removeEventListener( 'focusin', listeners.onDismissOnFocusLoss );
}
if ( listeners.onDismissOnClickOutside ) {
window.removeEventListener( 'click', listeners.onDismissOnClickOutside );
}
if ( listeners.onDismissOnEscape ) {
window.removeEventListener( 'keydown', listeners.onDismissOnEscape );
}
if ( listeners.onToggleOnClick ) {
button.removeEventListener( 'click', listeners.onToggleOnClick );
}
if ( listeners.onToggleOnSpaceEnter ) {
button.removeEventListener( 'keydown', listeners.onToggleOnSpaceEnter );
}
if ( listeners.onUpdateAriaExpandedOnInput ) {
checkbox.removeEventListener( 'input', listeners.onUpdateAriaExpandedOnInput );
}
2021-01-22 02:08:55 +00:00
}
module.exports = {
updateAriaExpanded: updateAriaExpanded,
bindUpdateAriaExpandedOnInput: bindUpdateAriaExpandedOnInput,
bindToggleOnClick: bindToggleOnClick,
bindToggleOnSpaceEnter: bindToggleOnSpaceEnter,
bindDismissOnClickOutside: bindDismissOnClickOutside,
bindDismissOnEscape: bindDismissOnEscape,
bindDismissOnFocusLoss: bindDismissOnFocusLoss,
bind: bind,
unbind: unbind
2021-01-22 02:08:55 +00:00
};