]> scripts.mit.edu Git - autoinstallsdev/mediawiki.git/blobdiff - vendor/oojs/oojs-ui/demos/demo.js
MediaWiki 1.30.2
[autoinstallsdev/mediawiki.git] / vendor / oojs / oojs-ui / demos / demo.js
diff --git a/vendor/oojs/oojs-ui/demos/demo.js b/vendor/oojs/oojs-ui/demos/demo.js
new file mode 100644 (file)
index 0000000..c41e6c0
--- /dev/null
@@ -0,0 +1,778 @@
+/* eslint-disable no-console */
+/* globals Prism, javascriptStringify */
+/**
+ * @class
+ * @extends OO.ui.Element
+ *
+ * @constructor
+ */
+window.Demo = function Demo() {
+       var demo = this;
+
+       // Parent constructor
+       Demo.parent.call( this );
+
+       // Mixin constructors
+       OO.EventEmitter.call( this );
+
+       // Normalization
+       this.normalizeQuery();
+
+       // Properties
+       this.stylesheetLinks = this.getStylesheetLinks();
+       this.mode = this.getCurrentMode();
+       this.$menu = $( '<div>' );
+       this.pageDropdown = new OO.ui.DropdownWidget( {
+               menu: {
+                       items: [
+                               new OO.ui.MenuOptionWidget( { data: 'dialogs', label: 'Dialogs' } ),
+                               new OO.ui.MenuOptionWidget( { data: 'icons', label: 'Icons' } ),
+                               new OO.ui.MenuOptionWidget( { data: 'toolbars', label: 'Toolbars' } ),
+                               new OO.ui.MenuOptionWidget( { data: 'widgets', label: 'Widgets' } )
+                       ]
+               },
+               classes: [ 'demo-pageDropdown' ]
+       } );
+       this.pageMenu = this.pageDropdown.getMenu();
+       this.themeSelect = new OO.ui.ButtonSelectWidget();
+       Object.keys( this.constructor.static.themes ).forEach( function ( theme ) {
+               demo.themeSelect.addItems( [
+                       new OO.ui.ButtonOptionWidget( {
+                               data: theme,
+                               label: demo.constructor.static.themes[ theme ]
+                       } )
+               ] );
+       } );
+       this.directionSelect = new OO.ui.ButtonSelectWidget().addItems( [
+               new OO.ui.ButtonOptionWidget( { data: 'ltr', label: 'LTR' } ),
+               new OO.ui.ButtonOptionWidget( { data: 'rtl', label: 'RTL' } )
+       ] );
+       this.jsPhpSelect = new OO.ui.ButtonGroupWidget().addItems( [
+               new OO.ui.ButtonWidget( { label: 'JS' } ).setActive( true ),
+               new OO.ui.ButtonWidget( {
+                       label: 'PHP',
+                       href: 'demos.php' + this.getUrlQuery( this.getCurrentFactorValues() )
+               } )
+       ] );
+       this.platformSelect = new OO.ui.ButtonSelectWidget().addItems( [
+               new OO.ui.ButtonOptionWidget( { data: 'desktop', label: 'Desktop' } ),
+               new OO.ui.ButtonOptionWidget( { data: 'mobile', label: 'Mobile' } )
+       ] );
+
+       this.documentationLink = new OO.ui.ButtonWidget( {
+               label: 'Docs',
+               classes: [ 'demo-button-docs' ],
+               icon: 'journal',
+               href: '../js/',
+               flags: [ 'progressive' ]
+       } );
+
+       // Events
+       this.pageMenu.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
+       this.themeSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
+       this.directionSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
+       this.platformSelect.on( 'choose', OO.ui.bind( this.onModeChange, this ) );
+
+       // Initialization
+       this.pageMenu.selectItemByData( this.mode.page );
+       this.themeSelect.selectItemByData( this.mode.theme );
+       this.directionSelect.selectItemByData( this.mode.direction );
+       this.platformSelect.selectItemByData( this.mode.platform );
+       this.$menu
+               .addClass( 'demo-menu' )
+               .attr( 'role', 'navigation' )
+               .append(
+                       this.pageDropdown.$element,
+                       this.themeSelect.$element,
+                       this.directionSelect.$element,
+                       this.jsPhpSelect.$element,
+                       this.platformSelect.$element,
+                       this.documentationLink.$element
+               );
+       this.$element
+               .addClass( 'demo' )
+               .append( this.$menu );
+       $( 'html' ).attr( 'dir', this.mode.direction );
+       $( 'head' ).append( this.stylesheetLinks );
+       // eslint-disable-next-line new-cap
+       OO.ui.theme = new OO.ui[ this.constructor.static.themes[ this.mode.theme ] + 'Theme' ]();
+       OO.ui.isMobile = function () {
+               return demo.mode.platform === 'mobile';
+       };
+};
+
+/* Setup */
+
+OO.inheritClass( Demo, OO.ui.Element );
+OO.mixinClass( Demo, OO.EventEmitter );
+
+/* Static Properties */
+
+/**
+ * Available pages.
+ *
+ * Populated by each of the page scripts in the `pages` directory.
+ *
+ * @static
+ * @property {Object.<string,Function>} pages List of functions that render a page, keyed by
+ *   symbolic page name
+ */
+Demo.static.pages = {};
+
+/**
+ * Available themes.
+ *
+ * Map of lowercase name to proper name. Lowercase names are used for linking to the
+ * correct stylesheet file. Proper names are used to find the theme class.
+ *
+ * @static
+ * @property {Object.<string,string>}
+ */
+Demo.static.themes = {
+       wikimediaui: 'WikimediaUI', // Do not change this line or you'll break `grunt add-theme`
+       apex: 'Apex'
+};
+
+/**
+ * Additional suffixes for which each theme defines image modules.
+ *
+ * @static
+ * @property {Object.<string,string[]>
+ */
+Demo.static.additionalThemeImagesSuffixes = {
+       wikimediaui: [
+               '-icons-movement',
+               '-icons-content',
+               '-icons-alerts',
+               '-icons-interactions',
+               '-icons-moderation',
+               '-icons-editing-core',
+               '-icons-editing-styling',
+               '-icons-editing-list',
+               '-icons-editing-advanced',
+               '-icons-media',
+               '-icons-location',
+               '-icons-user',
+               '-icons-layout',
+               '-icons-accessibility',
+               '-icons-wikimedia'
+       ],
+       apex: [
+               '-icons-movement',
+               '-icons-content',
+               '-icons-alerts',
+               '-icons-interactions',
+               '-icons-moderation',
+               '-icons-editing-core',
+               '-icons-editing-styling',
+               '-icons-editing-list',
+               '-icons-editing-advanced',
+               '-icons-media',
+               '-icons-user',
+               '-icons-layout',
+               '-icons-accessibility'
+       ]
+};
+
+/**
+ * Available text directions.
+ *
+ * List of text direction descriptions, each containing a `fileSuffix` property used for linking to
+ * the correct stylesheet file.
+ *
+ * @static
+ * @property {Object.<string,Object>}
+ */
+Demo.static.directions = {
+       ltr: { fileSuffix: '' },
+       rtl: { fileSuffix: '.rtl' }
+};
+
+/**
+ * Available platforms.
+ *
+ * @static
+ * @property {string[]}
+ */
+Demo.static.platforms = [ 'desktop', 'mobile' ];
+
+/**
+ * Default page.
+ *
+ * @static
+ * @property {string}
+ */
+Demo.static.defaultPage = 'widgets';
+
+/**
+ * Default page.
+ *
+ * Set by one of the page scripts in the `pages` directory.
+ *
+ * @static
+ * @property {string}
+ */
+Demo.static.defaultTheme = 'wikimediaui';
+
+/**
+ * Default page.
+ *
+ * Set by one of the page scripts in the `pages` directory.
+ *
+ * @static
+ * @property {string}
+ */
+Demo.static.defaultDirection = 'ltr';
+
+/**
+ * Default platform.
+ *
+ * Set by one of the page scripts in the `pages` directory.
+ *
+ * @static
+ * @property {string}
+ */
+Demo.static.defaultPlatform = 'desktop';
+
+/* Static Methods */
+
+/**
+ * Scroll to current fragment identifier. We have to do this manually because of the fixed header.
+ */
+Demo.static.scrollToFragment = function () {
+       var elem = document.getElementById( location.hash.slice( 1 ) );
+       if ( elem ) {
+               // The additional '10' is just because it looks nicer.
+               $( window ).scrollTop( $( elem ).offset().top - $( '.demo-menu' ).outerHeight() - 10 );
+       }
+};
+
+/* Methods */
+
+/**
+ * Load the demo page. Must be called after $element is attached.
+ *
+ * @return {jQuery.Promise} Resolved when demo is initialized
+ */
+Demo.prototype.initialize = function () {
+       var demo = this,
+               promises = this.stylesheetLinks.map( function ( el ) {
+                       return $( el ).data( 'load-promise' );
+               } );
+
+       // Helper function to get high resolution profiling data, where available.
+       function now() {
+               return ( window.performance && performance.now ) ? performance.now() :
+                       Date.now ? Date.now() : new Date().getTime();
+       }
+
+       return $.when.apply( $, promises )
+               .done( function () {
+                       var start, end;
+                       start = now();
+                       demo.constructor.static.pages[ demo.mode.page ]( demo );
+                       end = now();
+                       window.console.log( 'Took ' + ( end - start ) + ' ms to build demo page.' );
+               } )
+               .fail( function () {
+                       demo.$element.append( $( '<p>' ).text( 'Demo styles failed to load.' ) );
+               } );
+};
+
+/**
+ * Handle mode change events.
+ *
+ * Will load a new page.
+ */
+Demo.prototype.onModeChange = function () {
+       var page = this.pageMenu.getSelectedItem().getData(),
+               theme = this.themeSelect.getSelectedItem().getData(),
+               direction = this.directionSelect.getSelectedItem().getData(),
+               platform = this.platformSelect.getSelectedItem().getData();
+
+       history.pushState( null, document.title, this.getUrlQuery( [ page, theme, direction, platform ] ) );
+       $( window ).triggerHandler( 'popstate' );
+};
+
+/**
+ * Get URL query for given factors describing the demo's mode.
+ *
+ * @param {string[]} factors Factors, as returned e.g. by #getCurrentFactorValues
+ * @return {string} URL query part, starting with '?'
+ */
+Demo.prototype.getUrlQuery = function ( factors ) {
+       return '?page=' + factors[ 0 ] +
+               '&theme=' + factors[ 1 ] +
+               '&direction=' + factors[ 2 ] +
+               '&platform=' + factors[ 3 ] +
+               // Preserve current URL 'fragment' part
+               location.hash;
+};
+
+/**
+ * Get a list of mode factors.
+ *
+ * Factors are a mapping between symbolic names used in the URL query and internal information used
+ * to act on those symbolic names.
+ *
+ * Factor lists are in URL order: page, theme, direction, platform. Page contains the symbolic
+ * page name, others contain file suffixes.
+ *
+ * @return {Object[]} List of mode factors, keyed by symbolic name
+ */
+Demo.prototype.getFactors = function () {
+       var key,
+               factors = [ {}, {}, {}, {} ];
+
+       for ( key in this.constructor.static.pages ) {
+               factors[ 0 ][ key ] = key;
+       }
+       for ( key in this.constructor.static.themes ) {
+               factors[ 1 ][ key ] = '-' + key;
+       }
+       for ( key in this.constructor.static.directions ) {
+               factors[ 2 ][ key ] = this.constructor.static.directions[ key ].fileSuffix;
+       }
+       this.constructor.static.platforms.forEach( function ( platform ) {
+               factors[ 3 ][ platform ] = '';
+       } );
+
+       return factors;
+};
+
+/**
+ * Get a list of default factors.
+ *
+ * Factor defaults are in URL order: page, theme, direction, platform. Each contains a symbolic
+ * factor name which should be used as a fallback when the URL query is missing or invalid.
+ *
+ * @return {Object[]} List of default factors
+ */
+Demo.prototype.getDefaultFactorValues = function () {
+       return [
+               this.constructor.static.defaultPage,
+               this.constructor.static.defaultTheme,
+               this.constructor.static.defaultDirection,
+               this.constructor.static.defaultPlatform
+       ];
+};
+
+/**
+ * Parse the current URL query into factor values.
+ *
+ * @return {string[]} Factor values in URL order: page, theme, direction, platform
+ */
+Demo.prototype.getCurrentFactorValues = function () {
+       var i, parts, index,
+               factors = this.getDefaultFactorValues(),
+               order = [ 'page', 'theme', 'direction', 'platform' ],
+               query = location.search.slice( 1 ).split( '&' );
+       for ( i = 0; i < query.length; i++ ) {
+               parts = query[ i ].split( '=', 2 );
+               index = order.indexOf( parts[ 0 ] );
+               if ( index !== -1 ) {
+                       factors[ index ] = decodeURIComponent( parts[ 1 ] );
+               }
+       }
+       return factors;
+};
+
+/**
+ * Get the current mode.
+ *
+ * Generated from parsed URL query values.
+ *
+ * @return {Object} List of factor values keyed by factor name
+ */
+Demo.prototype.getCurrentMode = function () {
+       var factorValues = this.getCurrentFactorValues();
+
+       return {
+               page: factorValues[ 0 ],
+               theme: factorValues[ 1 ],
+               direction: factorValues[ 2 ],
+               platform: factorValues[ 3 ]
+       };
+};
+
+/**
+ * Get link elements for the current mode.
+ *
+ * @return {HTMLElement[]} List of link elements
+ */
+Demo.prototype.getStylesheetLinks = function () {
+       var i, len, links, fragments,
+               factors = this.getFactors(),
+               theme = this.getCurrentFactorValues()[ 1 ],
+               suffixes = this.constructor.static.additionalThemeImagesSuffixes[ theme ] || [],
+               urls = [];
+
+       // Translate modes to filename fragments
+       fragments = this.getCurrentFactorValues().map( function ( val, index ) {
+               return factors[ index ][ val ];
+       } );
+
+       // Theme styles
+       urls.push( 'dist/oojs-ui' + fragments.slice( 1 ).join( '' ) + '.css' );
+       for ( i = 0, len = suffixes.length; i < len; i++ ) {
+               urls.push( 'dist/oojs-ui' + fragments[ 1 ] + suffixes[ i ] + fragments[ 2 ] + '.css' );
+       }
+
+       // Demo styles
+       urls.push( 'styles/demo' + fragments[ 2 ] + '.css' );
+
+       // Add link tags
+       links = urls.map( function ( url ) {
+               var
+                       link = document.createElement( 'link' ),
+                       $link = $( link ),
+                       deferred = $.Deferred();
+               $link.data( 'load-promise', deferred.promise() );
+               $link.on( {
+                       load: deferred.resolve,
+                       error: deferred.reject
+               } );
+               link.rel = 'stylesheet';
+               link.href = url;
+               return link;
+       } );
+
+       return links;
+};
+
+/**
+ * Normalize the URL query.
+ */
+Demo.prototype.normalizeQuery = function () {
+       var i, len, factorValues, match, valid, factorValue,
+               modes = [],
+               factors = this.getFactors(),
+               defaults = this.getDefaultFactorValues();
+
+       factorValues = this.getCurrentFactorValues();
+       for ( i = 0, len = factors.length; i < len; i++ ) {
+               factorValue = factorValues[ i ];
+               modes[ i ] = factors[ i ][ factorValue ] !== undefined ? factorValue : defaults[ i ];
+       }
+
+       // Backwards-compatibility with old URLs that used the 'fragment' part to link to demo sections:
+       // if a fragment is specified and it describes valid factors, turn the URL into the new style.
+       match = location.hash.match( /^#(\w+)-(\w+)-(\w+)-(\w+)$/ );
+       if ( match ) {
+               factorValues = [];
+               valid = true;
+               for ( i = 0, len = factors.length; i < len; i++ ) {
+                       factorValue = match[ i + 1 ];
+                       if ( factors[ i ][ factorValue ] !== undefined ) {
+                               factorValues[ i ] = factorValue;
+                       } else {
+                               valid = false;
+                               break;
+                       }
+               }
+               if ( valid ) {
+                       location.hash = '';
+                       modes = factorValues;
+               }
+       }
+
+       // Update query
+       history.replaceState( null, document.title, this.getUrlQuery( modes ) );
+};
+
+/**
+ * Destroy demo.
+ */
+Demo.prototype.destroy = function () {
+       $( 'body' ).removeClass( 'oo-ui-ltr oo-ui-rtl' );
+       $( this.stylesheetLinks ).remove();
+       this.$element.remove();
+       this.emit( 'destroy' );
+};
+
+/**
+ * Build a console for interacting with an element.
+ *
+ * @param {OO.ui.Layout} item
+ * @param {string} layout Variable name for layout
+ * @param {string} widget Variable name for layout's field widget
+ * @return {jQuery} Console interface element
+ */
+Demo.prototype.buildConsole = function ( item, layout, widget, showLayoutCode ) {
+       var $toggle, $log, $label, $input, $submit, $console, $form, $pre, $code,
+               console = window.console;
+
+       function exec( str ) {
+               var func, ret;
+               if ( str.indexOf( 'return' ) !== 0 ) {
+                       str = 'return ' + str;
+               }
+               try {
+                       // eslint-disable-next-line no-new-func
+                       func = new Function( layout, widget, 'item', str );
+                       ret = { value: func( item, item.fieldWidget, item.fieldWidget ) };
+               } catch ( error ) {
+                       ret = {
+                               value: undefined,
+                               error: error
+                       };
+               }
+               return ret;
+       }
+
+       function submit() {
+               var val, result, logval;
+
+               val = $input.val();
+               $input.val( '' );
+               $input[ 0 ].focus();
+               result = exec( val );
+
+               logval = String( result.value );
+               if ( logval === '' ) {
+                       logval = '""';
+               }
+
+               $log.append(
+                       $( '<div>' )
+                               .addClass( 'demo-console-log-line demo-console-log-line-input' )
+                               .text( val ),
+                       $( '<div>' )
+                               .addClass( 'demo-console-log-line demo-console-log-line-return' )
+                               .text( logval || result.value )
+               );
+
+               if ( result.error ) {
+                       $log.append( $( '<div>' ).addClass( 'demo-console-log-line demo-console-log-line-error' ).text( result.error ) );
+               }
+
+               if ( console && console.log ) {
+                       console.log( '[demo]', result.value );
+                       if ( result.error ) {
+                               if ( console.error ) {
+                                       console.error( '[demo]', String( result.error ), result.error );
+                               } else {
+                                       console.log( '[demo] Error: ', result.error );
+                               }
+                       }
+               }
+
+               // Scrol to end
+               $log.prop( 'scrollTop', $log.prop( 'scrollHeight' ) );
+       }
+
+       function getCode( item, toplevel ) {
+               var config, defaultConfig, url, params, out, i,
+                       items = [],
+                       demoLinks = [],
+                       docLinks = [];
+
+               function getConstructorName( item ) {
+                       var isDemoWidget = item.constructor.name.indexOf( 'Demo' ) === 0;
+                       return ( isDemoWidget ? 'Demo.' : 'OO.ui.' ) + item.constructor.name.slice( 4 );
+               }
+
+               // If no item was passed we shouldn't show a code block
+               if ( item === undefined ) {
+                       return false;
+               }
+
+               config = item.initialConfig;
+
+               // Prevent the default config from being part of the code
+               if ( item instanceof OO.ui.ActionFieldLayout ) {
+                       defaultConfig = ( new item.constructor( new OO.ui.TextInputWidget(), new OO.ui.ButtonWidget() ) ).initialConfig;
+               } else if ( item instanceof OO.ui.FieldLayout ) {
+                       defaultConfig = ( new item.constructor( new OO.ui.ButtonWidget() ) ).initialConfig;
+               } else {
+                       defaultConfig = ( new item.constructor() ).initialConfig;
+               }
+               Object.keys( defaultConfig ).forEach( function ( key ) {
+                       if ( config[ key ] === defaultConfig[ key ] ) {
+                               delete config[ key ];
+                       } else if (
+                               typeof config[ key ] === 'object' && typeof defaultConfig[ key ] === 'object' &&
+                               OO.compare( config[ key ], defaultConfig[ key ] )
+                       ) {
+                               delete config[ key ];
+                       }
+               } );
+
+               config = javascriptStringify( config, function ( obj, indent, stringify ) {
+                       if ( obj instanceof Function ) {
+                               // Get function's source code, with extraneous indentation removed
+                               return obj.toString().replace( /^\t\t\t\t\t\t/gm, '' );
+                       } else if ( obj instanceof jQuery ) {
+                               if ( $.contains( item.$element[ 0 ], obj[ 0 ] ) ) {
+                                       // If this element appears inside the generated widget,
+                                       // assume this was something like `$label: $( '<p>Text</p>' )`
+                                       return '$( ' + javascriptStringify( obj.prop( 'outerHTML' ) ) + ' )';
+                               } else {
+                                       // Otherwise assume this was something like `$overlay: $( '#overlay' )`
+                                       return '$( ' + javascriptStringify( '#' + obj.attr( 'id' ) ) + ' )';
+                               }
+                       } else if ( obj instanceof OO.ui.HtmlSnippet ) {
+                               return 'new OO.ui.HtmlSnippet( ' + javascriptStringify( obj.toString() ) + ' )';
+                       } else if ( obj instanceof OO.ui.Element ) {
+                               return getCode( obj );
+                       } else {
+                               return stringify( obj );
+                       }
+               }, '\t' );
+
+               // The generated code needs to include different arguments, based on the object type
+               items.push( item );
+               if ( item instanceof OO.ui.ActionFieldLayout ) {
+                       params = getCode( item.fieldWidget ) + ', ' + getCode( item.buttonWidget );
+                       items.push( item.fieldWidget );
+                       items.push( item.buttonWidget );
+               } else if ( item instanceof OO.ui.FieldLayout ) {
+                       params = getCode( item.fieldWidget );
+                       items.push( item.fieldWidget );
+               } else {
+                       params = '';
+               }
+               if ( config !== '{}' ) {
+                       params += ( params ? ', ' : '' ) + config;
+               }
+               out = 'new ' + getConstructorName( item ) + '(' +
+                       ( params ? ' ' : '' ) + params + ( params ? ' ' : '' ) +
+                       ')';
+
+               if ( toplevel ) {
+                       for ( i = 0; i < items.length; i++ ) {
+                               item = items[ i ];
+                               // The code generated for Demo widgets cannot be copied and used
+                               if ( item.constructor.name.indexOf( 'Demo' ) === 0 ) {
+                                       url =
+                                               'https://phabricator.wikimedia.org/diffusion/GOJU/browse/master/demos/classes/' +
+                                               item.constructor.name.slice( 4 ) + '.js';
+                                       demoLinks.push( url );
+                               } else {
+                                       url = 'https://doc.wikimedia.org/oojs-ui/master/js/#!/api/' + getConstructorName( item );
+                                       url = '[' + url + '](' + url + ')';
+                                       docLinks.push( url );
+                               }
+                       }
+               }
+
+               return (
+                       ( docLinks.length ? '// See documentation at: \n// ' : '' ) +
+                       docLinks.join( '\n// ' ) + ( docLinks.length ? '\n' : '' ) +
+                       ( demoLinks.length ? '// See source code:\n// ' : '' ) +
+                       demoLinks.join( '\n// ' ) + ( demoLinks.length ? '\n' : '' ) +
+                       out
+               );
+       }
+
+       $toggle = $( '<span>' )
+               .addClass( 'demo-console-toggle' )
+               .attr( 'title', 'Toggle console' )
+               .on( 'click', function ( e ) {
+                       var code;
+                       e.preventDefault();
+                       $console.toggleClass( 'demo-console-collapsed demo-console-expanded' );
+                       if ( $input.is( ':visible' ) ) {
+                               $input[ 0 ].focus();
+                               if ( console && console.log ) {
+                                       window[ layout ] = item;
+                                       window[ widget ] = item.fieldWidget;
+                                       console.log( '[demo]', 'Globals ' + layout + ', ' + widget + ' have been set' );
+                                       console.log( '[demo]', item );
+
+                                       if ( showLayoutCode === true ) {
+                                               code = getCode( item, true );
+                                       } else {
+                                               code = getCode( item.fieldWidget, true );
+                                       }
+
+                                       if ( code ) {
+                                               $code.text( code );
+                                               Prism.highlightElement( $code[ 0 ] );
+                                       } else {
+                                               $code.remove();
+                                       }
+                               }
+                       }
+               } );
+
+       $log = $( '<div>' )
+               .addClass( 'demo-console-log' );
+
+       $label = $( '<label>' )
+               .addClass( 'demo-console-label' );
+
+       $input = $( '<input>' )
+               .addClass( 'demo-console-input' )
+               .prop( 'placeholder', '... (predefined: ' + layout + ', ' + widget + ')' );
+
+       $submit = $( '<div>' )
+               .addClass( 'demo-console-submit' )
+               .text( '↵' )
+               .on( 'click', submit );
+
+       $form = $( '<form>' ).on( 'submit', function ( e ) {
+               e.preventDefault();
+               submit();
+       } );
+
+       $code = $( '<code>' ).addClass( 'language-javascript' );
+
+       $pre = $( '<pre>' )
+               .addClass( 'demo-sample-code' )
+               .append( $code );
+
+       $console = $( '<div>' )
+               .addClass( 'demo-console demo-console-collapsed' )
+               .append(
+                       $toggle,
+                       $log,
+                       $form.append(
+                               $label.append(
+                                       $input
+                               ),
+                               $submit
+                       ),
+                       $pre
+               );
+
+       return $console;
+};
+
+/**
+ * Build a link to this example.
+ *
+ * @param {OO.ui.Layout} item
+ * @param {OO.ui.FieldsetLayout} parentItem
+ * @return {jQuery} Link interface element
+ */
+Demo.prototype.buildLinkExample = function ( item, parentItem ) {
+       var $linkExample, label, fragment;
+
+       if ( item.$label.text() === '' ) {
+               item = parentItem;
+       }
+       fragment = item.elementId;
+       if ( !fragment ) {
+               label = item.$label.text();
+               fragment = label.replace( /[^\w]+/g, '-' ).replace( /^-|-$/g, '' );
+               item.setElementId( fragment );
+       }
+
+       $linkExample = $( '<a>' )
+               .addClass( 'demo-link-example' )
+               .attr( 'title', 'Link to this example' )
+               .attr( 'href', '#' + fragment )
+               .on( 'click', function ( e ) {
+                       // We have to handle this manually in order to call .scrollToFragment() even if it's the same
+                       // fragment. Normally, the browser will scroll but not fire a 'hashchange' event in this
+                       // situation, and the scroll position will be off because of our fixed header.
+                       if ( e.which === OO.ui.MouseButtons.LEFT ) {
+                               location.hash = $( this ).attr( 'href' );
+                               Demo.static.scrollToFragment();
+                               e.preventDefault();
+                       }
+               } );
+
+       return $linkExample;
+};