1 /* eslint-disable no-console */
2 /* globals Prism, javascriptStringify */
5 * @extends OO.ui.Element
9 window.Demo = function Demo() {
13 Demo.parent.call( this );
16 OO.EventEmitter.call( this );
19 this.normalizeQuery();
22 this.stylesheetLinks = this.getStylesheetLinks();
23 this.mode = this.getCurrentMode();
24 this.$menu = $( '<div>' );
25 this.pageDropdown = new OO.ui.DropdownWidget( {
28 new OO.ui.MenuOptionWidget( { data: 'dialogs', label: 'Dialogs' } ),
29 new OO.ui.MenuOptionWidget( { data: 'icons', label: 'Icons' } ),
30 new OO.ui.MenuOptionWidget( { data: 'toolbars', label: 'Toolbars' } ),
31 new OO.ui.MenuOptionWidget( { data: 'widgets', label: 'Widgets' } )
34 classes: [ 'demo-pageDropdown' ]
36 this.pageMenu = this.pageDropdown.getMenu();
37 this.themeSelect = new OO.ui.ButtonSelectWidget();
38 Object.keys( this.constructor.static.themes ).forEach( function ( theme ) {
39 demo.themeSelect.addItems( [
40 new OO.ui.ButtonOptionWidget( {
42 label: demo.constructor.static.themes[ theme ]
46 this.directionSelect = new OO.ui.ButtonSelectWidget().addItems( [
47 new OO.ui.ButtonOptionWidget( { data: 'ltr', label: 'LTR' } ),
48 new OO.ui.ButtonOptionWidget( { data: 'rtl', label: 'RTL' } )
50 this.jsPhpSelect = new OO.ui.ButtonGroupWidget().addItems( [
51 new OO.ui.ButtonWidget( { label: 'JS' } ).setActive( true ),
52 new OO.ui.ButtonWidget( {
54 href: 'demos.php' + this.getUrlQuery( this.getCurrentFactorValues() )
57 this.platformSelect = new OO.ui.ButtonSelectWidget().addItems( [
58 new OO.ui.ButtonOptionWidget( { data: 'desktop', label: 'Desktop' } ),
59 new OO.ui.ButtonOptionWidget( { data: 'mobile', label: 'Mobile' } )
62 this.documentationLink = new OO.ui.ButtonWidget( {
64 classes: [ 'demo-button-docs' ],
67 flags: [ 'progressive' ]
71 this.pageMenu.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
72 this.themeSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
73 this.directionSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
74 this.platformSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
77 this.pageMenu.selectItemByData( this.mode.page );
78 this.themeSelect.selectItemByData( this.mode.theme );
79 this.directionSelect.selectItemByData( this.mode.direction );
80 this.platformSelect.selectItemByData( this.mode.platform );
82 .addClass( 'demo-menu' )
83 .attr( 'role', 'navigation' )
85 this.pageDropdown.$element,
86 this.themeSelect.$element,
87 this.directionSelect.$element,
88 this.jsPhpSelect.$element,
89 this.platformSelect.$element,
90 this.documentationLink.$element
94 .append( this.$menu );
95 $( 'html' ).attr( 'dir', this.mode.direction );
96 $( 'head' ).append( this.stylesheetLinks );
97 // eslint-disable-next-line new-cap
98 OO.ui.theme = new OO.ui[ this.constructor.static.themes[ this.mode.theme ] + 'Theme' ]();
99 OO.ui.isMobile = function () {
100 return demo.mode.platform === 'mobile';
106 OO.inheritClass( Demo, OO.ui.Element );
107 OO.mixinClass( Demo, OO.EventEmitter );
109 /* Static Properties */
114 * Populated by each of the page scripts in the `pages` directory.
117 * @property {Object.<string,Function>} pages List of functions that render a page, keyed by
120 Demo.static.pages = {};
125 * Map of lowercase name to proper name. Lowercase names are used for linking to the
126 * correct stylesheet file. Proper names are used to find the theme class.
129 * @property {Object.<string,string>}
131 Demo.static.themes = {
132 wikimediaui: 'WikimediaUI', // Do not change this line or you'll break `grunt add-theme`
137 * Additional suffixes for which each theme defines image modules.
140 * @property {Object.<string,string[]>
142 Demo.static.additionalThemeImagesSuffixes = {
147 '-icons-interactions',
149 '-icons-editing-core',
150 '-icons-editing-styling',
151 '-icons-editing-list',
152 '-icons-editing-advanced',
157 '-icons-accessibility',
164 '-icons-interactions',
166 '-icons-editing-core',
167 '-icons-editing-styling',
168 '-icons-editing-list',
169 '-icons-editing-advanced',
173 '-icons-accessibility'
178 * Available text directions.
180 * List of text direction descriptions, each containing a `fileSuffix` property used for linking to
181 * the correct stylesheet file.
184 * @property {Object.<string,Object>}
186 Demo.static.directions = {
187 ltr: { fileSuffix: '' },
188 rtl: { fileSuffix: '.rtl' }
192 * Available platforms.
195 * @property {string[]}
197 Demo.static.platforms = [ 'desktop', 'mobile' ];
205 Demo.static.defaultPage = 'widgets';
210 * Set by one of the page scripts in the `pages` directory.
215 Demo.static.defaultTheme = 'wikimediaui';
220 * Set by one of the page scripts in the `pages` directory.
225 Demo.static.defaultDirection = 'ltr';
230 * Set by one of the page scripts in the `pages` directory.
235 Demo.static.defaultPlatform = 'desktop';
240 * Scroll to current fragment identifier. We have to do this manually because of the fixed header.
242 Demo.static.scrollToFragment = function () {
243 var elem = document.getElementById( location.hash.slice( 1 ) );
245 // The additional '10' is just because it looks nicer.
246 $( window ).scrollTop( $( elem ).offset().top - $( '.demo-menu' ).outerHeight() - 10 );
253 * Load the demo page. Must be called after $element is attached.
255 * @return {jQuery.Promise} Resolved when demo is initialized
257 Demo.prototype.initialize = function () {
259 promises = this.stylesheetLinks.map( function ( el ) {
260 return $( el ).data( 'load-promise' );
263 // Helper function to get high resolution profiling data, where available.
265 return ( window.performance && performance.now ) ? performance.now() :
266 Date.now ? Date.now() : new Date().getTime();
269 return $.when.apply( $, promises )
273 demo.constructor.static.pages[ demo.mode.page ]( demo );
275 window.console.log( 'Took ' + ( end - start ) + ' ms to build demo page.' );
278 demo.$element.append( $( '<p>' ).text( 'Demo styles failed to load.' ) );
283 * Handle mode change events.
285 * Will load a new page.
287 Demo.prototype.onModeChange = function () {
288 var page = this.pageMenu.getSelectedItem().getData(),
289 theme = this.themeSelect.getSelectedItem().getData(),
290 direction = this.directionSelect.getSelectedItem().getData(),
291 platform = this.platformSelect.getSelectedItem().getData();
293 history.pushState( null, document.title, this.getUrlQuery( [ page, theme, direction, platform ] ) );
294 $( window ).triggerHandler( 'popstate' );
298 * Get URL query for given factors describing the demo's mode.
300 * @param {string[]} factors Factors, as returned e.g. by #getCurrentFactorValues
301 * @return {string} URL query part, starting with '?'
303 Demo.prototype.getUrlQuery = function ( factors ) {
304 return '?page=' + factors[ 0 ] +
305 '&theme=' + factors[ 1 ] +
306 '&direction=' + factors[ 2 ] +
307 '&platform=' + factors[ 3 ] +
308 // Preserve current URL 'fragment' part
313 * Get a list of mode factors.
315 * Factors are a mapping between symbolic names used in the URL query and internal information used
316 * to act on those symbolic names.
318 * Factor lists are in URL order: page, theme, direction, platform. Page contains the symbolic
319 * page name, others contain file suffixes.
321 * @return {Object[]} List of mode factors, keyed by symbolic name
323 Demo.prototype.getFactors = function () {
325 factors = [ {}, {}, {}, {} ];
327 for ( key in this.constructor.static.pages ) {
328 factors[ 0 ][ key ] = key;
330 for ( key in this.constructor.static.themes ) {
331 factors[ 1 ][ key ] = '-' + key;
333 for ( key in this.constructor.static.directions ) {
334 factors[ 2 ][ key ] = this.constructor.static.directions[ key ].fileSuffix;
336 this.constructor.static.platforms.forEach( function ( platform ) {
337 factors[ 3 ][ platform ] = '';
344 * Get a list of default factors.
346 * Factor defaults are in URL order: page, theme, direction, platform. Each contains a symbolic
347 * factor name which should be used as a fallback when the URL query is missing or invalid.
349 * @return {Object[]} List of default factors
351 Demo.prototype.getDefaultFactorValues = function () {
353 this.constructor.static.defaultPage,
354 this.constructor.static.defaultTheme,
355 this.constructor.static.defaultDirection,
356 this.constructor.static.defaultPlatform
361 * Parse the current URL query into factor values.
363 * @return {string[]} Factor values in URL order: page, theme, direction, platform
365 Demo.prototype.getCurrentFactorValues = function () {
367 factors = this.getDefaultFactorValues(),
368 order = [ 'page', 'theme', 'direction', 'platform' ],
369 query = location.search.slice( 1 ).split( '&' );
370 for ( i = 0; i < query.length; i++ ) {
371 parts = query[ i ].split( '=', 2 );
372 index = order.indexOf( parts[ 0 ] );
373 if ( index !== -1 ) {
374 factors[ index ] = decodeURIComponent( parts[ 1 ] );
381 * Get the current mode.
383 * Generated from parsed URL query values.
385 * @return {Object} List of factor values keyed by factor name
387 Demo.prototype.getCurrentMode = function () {
388 var factorValues = this.getCurrentFactorValues();
391 page: factorValues[ 0 ],
392 theme: factorValues[ 1 ],
393 direction: factorValues[ 2 ],
394 platform: factorValues[ 3 ]
399 * Get link elements for the current mode.
401 * @return {HTMLElement[]} List of link elements
403 Demo.prototype.getStylesheetLinks = function () {
404 var i, len, links, fragments,
405 factors = this.getFactors(),
406 theme = this.getCurrentFactorValues()[ 1 ],
407 suffixes = this.constructor.static.additionalThemeImagesSuffixes[ theme ] || [],
410 // Translate modes to filename fragments
411 fragments = this.getCurrentFactorValues().map( function ( val, index ) {
412 return factors[ index ][ val ];
416 urls.push( 'dist/oojs-ui' + fragments.slice( 1 ).join( '' ) + '.css' );
417 for ( i = 0, len = suffixes.length; i < len; i++ ) {
418 urls.push( 'dist/oojs-ui' + fragments[ 1 ] + suffixes[ i ] + fragments[ 2 ] + '.css' );
422 urls.push( 'styles/demo' + fragments[ 2 ] + '.css' );
425 links = urls.map( function ( url ) {
427 link = document.createElement( 'link' ),
429 deferred = $.Deferred();
430 $link.data( 'load-promise', deferred.promise() );
432 load: deferred.resolve,
433 error: deferred.reject
435 link.rel = 'stylesheet';
444 * Normalize the URL query.
446 Demo.prototype.normalizeQuery = function () {
447 var i, len, factorValues, match, valid, factorValue,
449 factors = this.getFactors(),
450 defaults = this.getDefaultFactorValues();
452 factorValues = this.getCurrentFactorValues();
453 for ( i = 0, len = factors.length; i < len; i++ ) {
454 factorValue = factorValues[ i ];
455 modes[ i ] = factors[ i ][ factorValue ] !== undefined ? factorValue : defaults[ i ];
458 // Backwards-compatibility with old URLs that used the 'fragment' part to link to demo sections:
459 // if a fragment is specified and it describes valid factors, turn the URL into the new style.
460 match = location.hash.match( /^#(\w+)-(\w+)-(\w+)-(\w+)$/ );
464 for ( i = 0, len = factors.length; i < len; i++ ) {
465 factorValue = match[ i + 1 ];
466 if ( factors[ i ][ factorValue ] !== undefined ) {
467 factorValues[ i ] = factorValue;
475 modes = factorValues;
480 history.replaceState( null, document.title, this.getUrlQuery( modes ) );
486 Demo.prototype.destroy = function () {
487 $( 'body' ).removeClass( 'oo-ui-ltr oo-ui-rtl' );
488 $( this.stylesheetLinks ).remove();
489 this.$element.remove();
490 this.emit( 'destroy' );
494 * Build a console for interacting with an element.
496 * @param {OO.ui.Layout} item
497 * @param {string} layout Variable name for layout
498 * @param {string} widget Variable name for layout's field widget
499 * @return {jQuery} Console interface element
501 Demo.prototype.buildConsole = function ( item, layout, widget, showLayoutCode ) {
502 var $toggle, $log, $label, $input, $submit, $console, $form, $pre, $code,
503 console = window.console;
505 function exec( str ) {
507 if ( str.indexOf( 'return' ) !== 0 ) {
508 str = 'return ' + str;
511 // eslint-disable-next-line no-new-func
512 func = new Function( layout, widget, 'item', str );
513 ret = { value: func( item, item.fieldWidget, item.fieldWidget ) };
524 var val, result, logval;
529 result = exec( val );
531 logval = String( result.value );
532 if ( logval === '' ) {
538 .addClass( 'demo-console-log-line demo-console-log-line-input' )
541 .addClass( 'demo-console-log-line demo-console-log-line-return' )
542 .text( logval || result.value )
545 if ( result.error ) {
546 $log.append( $( '<div>' ).addClass( 'demo-console-log-line demo-console-log-line-error' ).text( result.error ) );
549 if ( console && console.log ) {
550 console.log( '[demo]', result.value );
551 if ( result.error ) {
552 if ( console.error ) {
553 console.error( '[demo]', String( result.error ), result.error );
555 console.log( '[demo] Error: ', result.error );
561 $log.prop( 'scrollTop', $log.prop( 'scrollHeight' ) );
564 function getCode( item, toplevel ) {
565 var config, defaultConfig, url, params, out, i,
570 function getConstructorName( item ) {
571 var isDemoWidget = item.constructor.name.indexOf( 'Demo' ) === 0;
572 return ( isDemoWidget ? 'Demo.' : 'OO.ui.' ) + item.constructor.name.slice( 4 );
575 // If no item was passed we shouldn't show a code block
576 if ( item === undefined ) {
580 config = item.initialConfig;
582 // Prevent the default config from being part of the code
583 if ( item instanceof OO.ui.ActionFieldLayout ) {
584 defaultConfig = ( new item.constructor( new OO.ui.TextInputWidget(), new OO.ui.ButtonWidget() ) ).initialConfig;
585 } else if ( item instanceof OO.ui.FieldLayout ) {
586 defaultConfig = ( new item.constructor( new OO.ui.ButtonWidget() ) ).initialConfig;
588 defaultConfig = ( new item.constructor() ).initialConfig;
590 Object.keys( defaultConfig ).forEach( function ( key ) {
591 if ( config[ key ] === defaultConfig[ key ] ) {
592 delete config[ key ];
594 typeof config[ key ] === 'object' && typeof defaultConfig[ key ] === 'object' &&
595 OO.compare( config[ key ], defaultConfig[ key ] )
597 delete config[ key ];
601 config = javascriptStringify( config, function ( obj, indent, stringify ) {
602 if ( obj instanceof Function ) {
603 // Get function's source code, with extraneous indentation removed
604 return obj.toString().replace( /^\t\t\t\t\t\t/gm, '' );
605 } else if ( obj instanceof jQuery ) {
606 if ( $.contains( item.$element[ 0 ], obj[ 0 ] ) ) {
607 // If this element appears inside the generated widget,
608 // assume this was something like `$label: $( '<p>Text</p>' )`
609 return '$( ' + javascriptStringify( obj.prop( 'outerHTML' ) ) + ' )';
611 // Otherwise assume this was something like `$overlay: $( '#overlay' )`
612 return '$( ' + javascriptStringify( '#' + obj.attr( 'id' ) ) + ' )';
614 } else if ( obj instanceof OO.ui.HtmlSnippet ) {
615 return 'new OO.ui.HtmlSnippet( ' + javascriptStringify( obj.toString() ) + ' )';
616 } else if ( obj instanceof OO.ui.Element ) {
617 return getCode( obj );
619 return stringify( obj );
623 // The generated code needs to include different arguments, based on the object type
625 if ( item instanceof OO.ui.ActionFieldLayout ) {
626 params = getCode( item.fieldWidget ) + ', ' + getCode( item.buttonWidget );
627 items.push( item.fieldWidget );
628 items.push( item.buttonWidget );
629 } else if ( item instanceof OO.ui.FieldLayout ) {
630 params = getCode( item.fieldWidget );
631 items.push( item.fieldWidget );
635 if ( config !== '{}' ) {
636 params += ( params ? ', ' : '' ) + config;
638 out = 'new ' + getConstructorName( item ) + '(' +
639 ( params ? ' ' : '' ) + params + ( params ? ' ' : '' ) +
643 for ( i = 0; i < items.length; i++ ) {
645 // The code generated for Demo widgets cannot be copied and used
646 if ( item.constructor.name.indexOf( 'Demo' ) === 0 ) {
648 'https://phabricator.wikimedia.org/diffusion/GOJU/browse/master/demos/classes/' +
649 item.constructor.name.slice( 4 ) + '.js';
650 demoLinks.push( url );
652 url = 'https://doc.wikimedia.org/oojs-ui/master/js/#!/api/' + getConstructorName( item );
653 url = '[' + url + '](' + url + ')';
654 docLinks.push( url );
660 ( docLinks.length ? '// See documentation at: \n// ' : '' ) +
661 docLinks.join( '\n// ' ) + ( docLinks.length ? '\n' : '' ) +
662 ( demoLinks.length ? '// See source code:\n// ' : '' ) +
663 demoLinks.join( '\n// ' ) + ( demoLinks.length ? '\n' : '' ) +
668 $toggle = $( '<span>' )
669 .addClass( 'demo-console-toggle' )
670 .attr( 'title', 'Toggle console' )
671 .on( 'click', function ( e ) {
674 $console.toggleClass( 'demo-console-collapsed demo-console-expanded' );
675 if ( $input.is( ':visible' ) ) {
677 if ( console && console.log ) {
678 window[ layout ] = item;
679 window[ widget ] = item.fieldWidget;
680 console.log( '[demo]', 'Globals ' + layout + ', ' + widget + ' have been set' );
681 console.log( '[demo]', item );
683 if ( showLayoutCode === true ) {
684 code = getCode( item, true );
686 code = getCode( item.fieldWidget, true );
691 Prism.highlightElement( $code[ 0 ] );
700 .addClass( 'demo-console-log' );
702 $label = $( '<label>' )
703 .addClass( 'demo-console-label' );
705 $input = $( '<input>' )
706 .addClass( 'demo-console-input' )
707 .prop( 'placeholder', '... (predefined: ' + layout + ', ' + widget + ')' );
709 $submit = $( '<div>' )
710 .addClass( 'demo-console-submit' )
712 .on( 'click', submit );
714 $form = $( '<form>' ).on( 'submit', function ( e ) {
719 $code = $( '<code>' ).addClass( 'language-javascript' );
722 .addClass( 'demo-sample-code' )
725 $console = $( '<div>' )
726 .addClass( 'demo-console demo-console-collapsed' )
743 * Build a link to this example.
745 * @param {OO.ui.Layout} item
746 * @param {OO.ui.FieldsetLayout} parentItem
747 * @return {jQuery} Link interface element
749 Demo.prototype.buildLinkExample = function ( item, parentItem ) {
750 var $linkExample, label, fragment;
752 if ( item.$label.text() === '' ) {
755 fragment = item.elementId;
757 label = item.$label.text();
758 fragment = label.replace( /[^\w]+/g, '-' ).replace( /^-|-$/g, '' );
759 item.setElementId( fragment );
762 $linkExample = $( '<a>' )
763 .addClass( 'demo-link-example' )
764 .attr( 'title', 'Link to this example' )
765 .attr( 'href', '#' + fragment )
766 .on( 'click', function ( e ) {
767 // We have to handle this manually in order to call .scrollToFragment() even if it's the same
768 // fragment. Normally, the browser will scroll but not fire a 'hashchange' event in this
769 // situation, and the scroll position will be off because of our fixed header.
770 if ( e.which === OO.ui.MouseButtons.LEFT ) {
771 location.hash = $( this ).attr( 'href' );
772 Demo.static.scrollToFragment();