/**
* Provides methods to create CSS-only Codex components.
*
* @todo Move HTML generation to Mustache templates.
*/
class CodeMirrorCodex {
constructor() {
/** @type {HTMLDivElement|null} */
this.dialog = null;
/** @type {HTMLElement|null} */
this.focusedElement = null;
}
/**
* Get a CSS-only Codex TextInput.
*
* @param {string} name
* @param {string} [value='']
* @param {string} placeholder
* @return {Array<HTMLElement>} [HTMLDivElement, HTMLInputElement]
* @internal
*/
getTextInput( name, value = '', placeholder = '' ) {
const wrapper = document.createElement( 'div' );
wrapper.className = 'cdx-text-input cm-mw-panel--text-input';
const input = document.createElement( 'input' );
input.className = 'cdx-text-input__input';
input.type = 'text';
input.name = name;
// The following messages may be used here:
// * codemirror-find
// * codemirror-replace-placeholder
input.placeholder = placeholder ? mw.msg( placeholder ) : '';
input.value = value;
wrapper.appendChild( input );
return [ wrapper, input ];
}
/**
* Get a CSS-only Codex Button.
*
* @param {string} label
* @param {Object} [opts]
* @param {string|null} [opts.icon=null]
* @param {boolean} [opts.iconOnly=false]
* @param {string} [opts.action='default']
* @param {string} [opts.weight='normal']
* @return {HTMLButtonElement}
* @internal
*/
getButton( label, opts = {} ) {
opts = Object.assign(
{ icon: null, iconOnly: false, action: 'default', weight: 'normal' },
opts
);
const button = document.createElement( 'button' );
button.className = 'cdx-button cm-mw-panel--button';
// The following CSS classes may be used here:
// * cdx-button--action-default
// * cdx-button--action-progressive
// * cdx-button--action-destructive
button.classList.add( `cdx-button--action-${ opts.action }` );
// The following CSS classes may be used here:
// * cdx-button--size-normal
// * cdx-button--size-primary
// * cdx-button--size-quiet
button.classList.add( `cdx-button--weight-${ opts.weight }` );
button.type = 'button';
if ( opts.icon ) {
const iconSpan = document.createElement( 'span' );
// The following CSS classes may be used here:
// * cm-mw-icon--previous
// * cm-mw-icon--next
// * cm-mw-icon--all
// * cm-mw-icon--replace
// * cm-mw-icon--replace-all
// * cm-mw-icon--done
// * cm-mw-icon--goto-line-go
iconSpan.className = 'cdx-button__icon cm-mw-icon--' + opts.icon;
if ( !opts.iconOnly ) {
iconSpan.setAttribute( 'aria-hidden', 'true' );
}
button.appendChild( iconSpan );
}
// The following messages may be used here:
// * codemirror-next
// * codemirror-previous
// * codemirror-all
// * codemirror-replace
// * codemirror-replace-all
const message = mw.msg( label );
if ( opts.iconOnly ) {
button.classList.add( 'cdx-button--icon-only' );
button.title = message;
button.setAttribute( 'aria-label', message );
} else {
button.append( message );
}
return button;
}
/**
* Get a CSS-only Codex Checkbox.
*
* @param {string} name
* @param {string} label
* @param {boolean} [checked=false]
* @return {Array<HTMLElement>} [HTMLSpanElement, HTMLInputElement]
* @internal
*/
getCheckbox( name, label, checked = false ) {
const wrapper = document.createElement( 'span' );
wrapper.className = 'cdx-checkbox cdx-checkbox--inline cm-mw-panel--checkbox';
const input = document.createElement( 'input' );
input.className = 'cdx-checkbox__input';
input.id = `cm-mw__${ this.getRandomId() }`;
input.type = 'checkbox';
input.name = name;
input.checked = checked;
wrapper.appendChild( input );
const emptyIcon = document.createElement( 'span' );
emptyIcon.className = 'cdx-checkbox__icon';
wrapper.appendChild( emptyIcon );
const labelWrapper = document.createElement( 'div' );
labelWrapper.className = 'cdx-checkbox__label cdx-label';
const labelElement = document.createElement( 'label' );
labelElement.className = 'cdx-label__label';
labelElement.htmlFor = input.id;
const innerSpan = document.createElement( 'span' );
innerSpan.className = 'cdx-label__label__text';
// The following messages may be used here:
// * codemirror-match-case
// * codemirror-regexp
// * codemirror-by-word
innerSpan.textContent = mw.msg( label );
labelElement.appendChild( innerSpan );
labelWrapper.appendChild( labelElement );
wrapper.appendChild( labelWrapper );
return [ wrapper, input ];
}
/**
* Get a random ID string.
*
* @return {string}
* @private
*/
getRandomId() {
// Based on https://stackoverflow.com/a/6860916/604142 (CC BY-SA 3.0)
// We don't need cryptographic quality randomness here, just something to
// avoid collisions in the DOM.
// eslint-disable-next-line no-bitwise
return ( ( ( 1 + Math.random() ) * 0x10000 ) | 0 )
.toString( 16 )
.slice( 1 );
}
/**
* Get a CSS-only Codex ToggleButton.
*
* @param {string} name
* @param {string} label
* @param {string} icon
* @param {boolean} [checked=false]
* @return {HTMLButtonElement}
* @internal
*/
getToggleButton( name, label, icon, checked = false ) {
const btn = document.createElement( 'button' );
// The following CSS classes may be used here:
// * cdx-toggle-button--toggled-on
// * cdx-toggle-button--toggled-off
btn.className = 'cdx-toggle-button cdx-toggle-button--framed ' +
`cdx-toggle-button--toggled-${ checked ? 'on' : 'off' } cm-mw-panel--toggle-button`;
btn.type = 'button';
btn.dataset.checked = String( checked );
btn.setAttribute( 'aria-pressed', checked );
// The following messages may be used here:
// * codemirror-match-case
// * codemirror-regexp
// * codemirror-by-word
const message = mw.msg( label );
btn.title = message;
btn.setAttribute( 'aria-label', message );
// Add the icon.
const iconWrapper = document.createElement( 'span' );
// The following CSS classes may be used here:
// * cm-mw-icon--match-case
// * cm-mw-icon--regexp
// * cm-mw-icon--quotes
iconWrapper.className = 'cdx-icon cdx-icon--medium cm-mw-icon--' + icon;
btn.appendChild( iconWrapper );
// Add the click handler.
btn.addEventListener( 'click', ( e ) => {
e.preventDefault();
const toggled = btn.dataset.checked === 'true';
btn.dataset.checked = String( !toggled );
btn.setAttribute( 'aria-pressed', String( !toggled ) );
btn.classList.toggle( 'cdx-toggle-button--toggled-on', !toggled );
btn.classList.toggle( 'cdx-toggle-button--toggled-off', toggled );
} );
return btn;
}
/**
* Get a CSS-only Codex fieldset with a legend.
*
* @param {string|HTMLElement} legendText
* @param {...HTMLElement[]} fields
* @return {HTMLFieldSetElement}
* @internal
*/
getFieldset( legendText, ...fields ) {
const fieldset = document.createElement( 'fieldset' );
fieldset.className = 'cm-mw-panel--fieldset cdx-field';
const legend = document.createElement( 'legend' );
legend.className = 'cdx-label';
const innerSpan = document.createElement( 'span' );
innerSpan.className = 'cdx-label__label__text';
if ( legendText instanceof HTMLElement ) {
innerSpan.appendChild( legendText );
} else {
innerSpan.textContent = legendText;
}
legend.appendChild( innerSpan );
fieldset.appendChild( legend );
fieldset.append( ...fields );
return fieldset;
}
/**
* Show a Codex Dialog.
*
* This implements a vanilla JS port of the Codex Dialog component. See https://w.wiki/CcWY
*
* @param {string} title
* @param {string} name Constructed into the CSS class `cm-mw-${name}-dialog`
* @param {HTMLElement|HTMLElement[]} contents
* @param {HTMLElement|HTMLElement[]} [actions] Buttons or other actions to show in the footer.
* @return {HTMLDivElement}
* @internal
*/
showDialog( title, name, contents, actions = [] ) {
if ( this.dialog ) {
this.animateDialog( true );
return this.dialog;
}
contents = Array.isArray( contents ) ? contents : [ contents ];
actions = Array.isArray( actions ) ? actions : [ actions ];
const backdrop = document.createElement( 'div' );
backdrop.classList.add(
'cdx-dialog-backdrop',
// These classes are used by the fade animation.
// We always want them enabled, since dialog content is not interactable
// and thus we don't need to worry about conflicting styles.
'cdx-dialog-fade-enter-active',
'cm-mw-dialog-backdrop',
'cm-mw-dialog--hidden'
);
const tabindex = document.createElement( 'div' );
tabindex.tabIndex = 0;
backdrop.appendChild( tabindex );
const dialog = document.createElement( 'div' );
// The following CSS classes may be used here:
// * cm-mw-preferences-dialog
// * cm-mw-keymap-dialog
dialog.classList.add( 'cdx-dialog', 'cm-mw-dialog', `cm-mw-${ name }-dialog` );
backdrop.appendChild( dialog );
backdrop.addEventListener( 'click', ( e ) => {
if ( e.target === backdrop ) {
this.animateDialog( false );
}
} );
const header = document.createElement( 'header' );
header.classList.add( 'cdx-dialog__header', 'cdx-dialog__header--default' );
const headerTitleGroup = document.createElement( 'div' );
headerTitleGroup.classList.add( 'cdx-dialog__header__title-group' );
const h2 = document.createElement( 'h2' );
h2.className = 'cdx-dialog__header__title';
// The following messages may be used here:
// * codemirror-keymap-help-title
// * codemirror-prefs-title
h2.textContent = mw.msg( title );
headerTitleGroup.appendChild( h2 );
header.appendChild( headerTitleGroup );
const closeBtn = document.createElement( 'button' );
closeBtn.type = 'button';
closeBtn.classList.add(
'cdx-button',
'cdx-button',
'cdx-button--action-default',
'cdx-button--weight-quiet',
'cdx-button--size-medium',
'cdx-button--icon-only',
'cdx-dialog__header__close-button',
'cdx-dialog__header__close'
);
closeBtn.setAttribute( 'aria-label', mw.msg( 'codemirror-keymap-help-close' ) );
const cdxIcon = document.createElement( 'span' );
cdxIcon.classList.add( 'cdx-button__icon', 'cm-mw-icon--close' );
closeBtn.appendChild( cdxIcon );
closeBtn.addEventListener( 'click', this.animateDialog.bind( this, false ) );
header.appendChild( closeBtn );
dialog.appendChild( header );
const focusTrap = document.createElement( 'div' );
focusTrap.tabIndex = -1;
dialog.appendChild( focusTrap );
const body = document.createElement( 'div' );
body.classList.add( 'cdx-dialog__body' );
body.append( ...contents );
dialog.appendChild( body );
if ( actions.length ) {
dialog.classList.add( 'cdx-dialog--horizontal-actions' );
const footer = document.createElement( 'footer' );
footer.className = 'cdx-dialog__footer cdx-dialog__footer--default';
const footerActions = document.createElement( 'div' );
footerActions.classList.add( 'cdx-dialog__footer__actions' );
footerActions.append( ...actions );
footer.appendChild( footerActions );
dialog.appendChild( footer );
}
backdrop.appendChild( tabindex.cloneNode() );
this.dialog = backdrop;
document.body.appendChild( backdrop );
this.animateDialog( true );
return this.dialog;
}
/**
* Fade the dialog in or out, adjusting for scrollbar widths to prevent shifting of content.
* This almost fully mimics the way the Codex handles its Dialog component, with the exception
* that we don't force a focus trap, nor do we set aria-hidden on other elements in the DOM.
* This is to keep our implementation simple until something like T382532 is realized.
*
* @param {boolean} open
* @protected
*/
animateDialog( open = false ) {
if ( open ) {
this.focusedElement = document.activeElement;
}
document.activeElement.blur();
// Must be unhidden in order to animate.
this.dialog.classList.remove( 'cm-mw-dialog--hidden' );
// When the transition ends, hide or show the dialog.
this.dialog.addEventListener( 'transitionend', () => {
this.dialog.classList.toggle( 'cm-mw-dialog--hidden', !open );
if ( open ) {
this.dialog.querySelector( '[tabindex="0"]' ).focus();
// Determine the width of the scrollbar and compensate for it if necessary
const scrollWidth = window.innerWidth - document.documentElement.clientWidth;
document.documentElement.style.setProperty( 'margin-right', `${ scrollWidth }px` );
} else {
document.documentElement.style.removeProperty( 'margin-right' );
// Put focus back to wherever it was before opening the dialog.
if ( this.focusedElement ) {
this.focusedElement.focus();
this.focusedElement = null;
}
}
// Toggle a class on <body> to prevent scrolling
document.body.classList.toggle( 'cdx-dialog-open', open );
}, { once: true } );
// Animates the dialog in or out.
// Use setTimeout() with slight delay to allow rendering threads to catch up.
setTimeout( () => {
this.dialog.classList.toggle( 'cm-mw-dialog-animate-show', open );
}, 50 );
// Add or remove the keydown listener.
if ( open && !this.keydownListener ) {
this.keydownListener = ( e ) => {
if ( e.key === 'Escape' && !this.dialog.classList.contains( 'cm-mw-dialog--hidden' ) ) {
this.animateDialog( false );
}
};
document.body.addEventListener( 'keydown', this.keydownListener );
} else if ( !open && this.keydownListener ) {
document.body.removeEventListener( 'keydown', this.keydownListener );
this.keydownListener = null;
}
}
}
module.exports = CodeMirrorCodex;