diff --git a/packages/ckeditor5-list/lang/contexts.json b/packages/ckeditor5-list/lang/contexts.json index 5ee754657e0..33cd5248b91 100644 --- a/packages/ckeditor5-list/lang/contexts.json +++ b/packages/ckeditor5-list/lang/contexts.json @@ -1,5 +1,25 @@ { "Numbered List": "Toolbar button tooltip for the Numbered List feature.", "Bulleted List": "Toolbar button tooltip for the Bulleted List feature.", - "To-do List": "Toolbar button tooltip for the To-do List feature." + "To-do List": "Toolbar button tooltip for the To-do List feature.", + "Bulleted list styles toolbar": "The ARIA label of the toolbar displaying buttons allowing users to change the bulleted list style.", + "Numbered list styles toolbar": "The ARIA label of the toolbar displaying buttons allowing users to change the numbered list style.", + "Toggle the disc list style": "The ARIA label of the button that toggles the \"disc\" list style.", + "Toggle the circle list style": "The ARIA label of the button that toggles the \"circle\" list style.", + "Toggle the square list style": "The ARIA label of the button that toggles the \"square\" list style.", + "Toggle the decimal list style": "The ARIA label of the button that toggles the \"decimal\" list style.", + "Toggle the decimal with leading zero list style": "The ARIA label of the button that toggles the \"decimal with leading zero\" list style.", + "Toggle the lower–roman list style": "The ARIA label of the button that toggles the \"lower–roman\" list style.", + "Toggle the upper–roman list style": "The ARIA label of the button that toggles the \"upper–roman\" list style.", + "Toggle the lower–latin list style": "The ARIA label of the button that toggles the \"lower–latin\" list style.", + "Toggle the upper–latin list style": "The ARIA label of the button that toggles the \"upper–latin\" list style.", + "Disc": "The tooltip text of the button that toggles the \"disc\" list style.", + "Circle": "The tooltip text of the button that toggles the \"circle\" list style.", + "Square": "The tooltip text of the button that toggles the \"square\" list style.", + "Decimal": "The tooltip text of the button that toggles the \"decimal\" list style.", + "Decimal with leading zero": "The tooltip text of the button that toggles the \"decimal with leading zero\" list style.", + "Lower–roman": "The tooltip text of the button that toggles the \"lower–roman\" list style.", + "Upper-roman": "The tooltip text of the button that toggles the \"upper–roman\" list style.", + "Lower-latin": "The tooltip text of the button that toggles the \"lower–latin\" list style.", + "Upper-latin": "The tooltip text of the button that toggles the \"upper–latin\" list style." } diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index ab7c31c2ed8..9ddf0879424 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -20,7 +20,7 @@ "@ckeditor/ckeditor5-basic-styles": "^21.0.0", "@ckeditor/ckeditor5-block-quote": "^21.0.0", "@ckeditor/ckeditor5-clipboard": "^21.0.0", - "@ckeditor/ckeditor5-essential": "^21.0.0", + "@ckeditor/ckeditor5-essentials": "^21.0.0", "@ckeditor/ckeditor5-editor-classic": "^21.0.0", "@ckeditor/ckeditor5-enter": "^21.0.0", "@ckeditor/ckeditor5-font": "^21.0.0", diff --git a/packages/ckeditor5-list/src/liststylesui.js b/packages/ckeditor5-list/src/liststylesui.js index dbd4919f594..1d01d596114 100644 --- a/packages/ckeditor5-list/src/liststylesui.js +++ b/packages/ckeditor5-list/src/liststylesui.js @@ -8,8 +8,35 @@ */ import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview'; +import { + createDropdown, + addToolbarToDropdown +} from '@ckeditor/ckeditor5-ui/src/dropdown/utils'; + +import bulletedListIcon from '../theme/icons/bulletedlist.svg'; +import numberedListIcon from '../theme/icons/numberedlist.svg'; + +import listStyleDiscIcon from '../theme/icons/liststyledisc.svg'; +import listStyleCircleIcon from '../theme/icons/liststylecircle.svg'; +import listStyleSquareIcon from '../theme/icons/liststylesquare.svg'; +import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg'; +import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg'; +import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg'; +import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg'; +import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg'; +import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg'; + +import '../theme/liststyles.css'; /** + * The list styles UI plugin. It introduces the extended `'bulletedList'` and `'numberedList'` toolbar + * buttons that allow users change styles of individual lists in the content. + * + * **Note**: Buttons introduces by this plugin override implementations from the {@link module:list/listui~ListUI} + * (because they share the same names). + * * @extends module:core/plugin~Plugin */ export default class ListStylesUI extends Plugin { @@ -21,6 +48,181 @@ export default class ListStylesUI extends Plugin { } init() { - return null; + const editor = this.editor; + const t = editor.locale.t; + + editor.ui.componentFactory.add( 'bulletedList', getSplitButtonCreator( { + editor, + parentCommandName: 'bulletedList', + buttonLabel: t( 'Bulleted List' ), + buttonIcon: bulletedListIcon, + toolbarAriaLabel: t( 'Bulleted list styles toolbar' ), + styleDefinitions: [ + { + label: t( 'Toggle the disc list style' ), + tooltip: t( 'Disc' ), + type: 'disc', + icon: listStyleDiscIcon + }, + { + label: t( 'Toggle the circle list style' ), + tooltip: t( 'Circle' ), + type: 'circle', + icon: listStyleCircleIcon + }, + { + label: t( 'Toggle the square list style' ), + tooltip: t( 'Square' ), + type: 'square', + icon: listStyleSquareIcon + } + ] + } ) ); + + editor.ui.componentFactory.add( 'numberedList', getSplitButtonCreator( { + editor, + parentCommandName: 'numberedList', + buttonLabel: t( 'Numbered List' ), + buttonIcon: numberedListIcon, + toolbarAriaLabel: t( 'Numbered list styles toolbar' ), + styleDefinitions: [ + { + label: t( 'Toggle the decimal list style' ), + tooltip: t( 'Decimal' ), + type: 'decimal', + icon: listStyleDecimalIcon + }, + { + label: t( 'Toggle the decimal with leading zero list style' ), + tooltip: t( 'Decimal with leading zero' ), + type: 'decimal-leading-zero', + icon: listStyleDecimalWithLeadingZeroIcon + }, + { + label: t( 'Toggle the lower–roman list style' ), + tooltip: t( 'Lower–roman' ), + type: 'lower-roman', + icon: listStyleLowerRomanIcon + }, + { + label: t( 'Toggle the upper–roman list style' ), + tooltip: t( 'Upper-roman' ), + type: 'upper-roman', + icon: listStyleUpperRomanIcon + }, + { + label: t( 'Toggle the lower–latin list style' ), + tooltip: t( 'Lower-latin' ), + type: 'lower-latin', + icon: listStyleLowerLatinIcon + }, + { + label: t( 'Toggle the upper–latin list style' ), + tooltip: t( 'Upper-latin' ), + type: 'upper-latin', + icon: listStyleUpperLatinIcon + } + ] + } ) ); } } + +// A helper that returns a function that creates a split button with a toolbar in the dropdown, +// which in turn contains buttons allowing users to change list styles in the context of the current selection. +// +// @param {Object} options +// @param {module:core/editor/editor~Editor} options.editor +// @param {'bulletedList'|'numberedList'} options.parentCommandName The name of the higher-order editor command associated with +// the set of particular list styles (e.g. "bulletedList" for "disc", "circle", and "square" styles). +// @param {String} options.buttonLabel Label of the main part of the split button. +// @param {String} options.buttonIcon The SVG string of an icon for the main part of the split button. +// @param {String} options.toolbarAriaLabel The ARIA label for the toolbar in the split button dropdown. +// @param {Object} options.styleDefinitions Definitions of the style buttons. +// @returns {Function} A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}. +function getSplitButtonCreator( { editor, parentCommandName, buttonLabel, buttonIcon, toolbarAriaLabel, styleDefinitions } ) { + const parentCommand = editor.commands.get( parentCommandName ); + const listStylesCommand = editor.commands.get( 'listStyles' ); + + // @param {module:utils/locale~Locale} locale + // @returns {module:ui/dropdown/dropdownview~DropdownView} + return locale => { + const dropdownView = createDropdown( locale, SplitButtonView ); + const splitButtonView = dropdownView.buttonView; + const styleButtonCreator = getStyleButtonCreator( { editor, parentCommandName, listStylesCommand } ); + + addToolbarToDropdown( dropdownView, styleDefinitions.map( styleButtonCreator ) ); + + dropdownView.bind( 'isEnabled' ).to( parentCommand ); + dropdownView.toolbarView.ariaLabel = toolbarAriaLabel; + dropdownView.class = 'ck-list-styles-dropdown'; + + splitButtonView.on( 'execute', () => { + editor.execute( parentCommandName ); + editor.editing.view.focus(); + } ); + + splitButtonView.set( { + label: buttonLabel, + icon: buttonIcon, + tooltip: true, + isToggleable: true + } ); + + splitButtonView.bind( 'isOn' ).to( parentCommand, 'value', value => !!value ); + + return dropdownView; + }; +} + +// A helper that returns a function (factory) that creates individual buttons used by users to change styles +// of lists. +// +// @param {Object} options +// @param {module:core/editor/editor~Editor} options.editor +// @param {module:list/liststylescommand~ListStylesCommand} options.listStylesCommand The instance of the `ListStylesCommand` class. +// @param {'bulletedList'|'numberedList'} options.parentCommandName The name of the higher-order command associated with a +// particular list style (e.g. "bulletedList" is associated with "square" and "numberedList" is associated with "roman"). +// @returns {Function} A function that can be passed straight into {@link module:ui/componentfactory~ComponentFactory#add}. +function getStyleButtonCreator( { editor, listStylesCommand, parentCommandName } ) { + const locale = editor.locale; + const parentCommand = editor.commands.get( parentCommandName ); + + // @param {String} label The label of the style button. + // @param {String} type The type of the style button (e.g. "roman" or "circle"). + // @param {String} icon The SVG string of an icon of the style button. + // @param {String} tooltip The tooltip text of the button (shorter than verbose label). + // @returns {module:ui/button/buttonview~ButtonView} + return ( { label, type, icon, tooltip } ) => { + const button = new ButtonView( locale ); + + button.set( { label, icon, tooltip } ); + + listStylesCommand.on( 'change:value', () => { + button.isOn = listStylesCommand.value === type; + } ); + + button.on( 'execute', () => { + // If the content the selection is anchored to is a list, let's change its style. + if ( parentCommand.value ) { + // If the current list style is not set in the model or the style is different than the + // one to be applied, simply apply the new style. + if ( listStylesCommand.value !== type ) { + editor.execute( 'listStyles', { type } ); + } + // If the style was the same, remove it (the button works as an off toggle). + else { + editor.execute( 'listStyles', { type: 'default' } ); + } + } + // If the content the selection is anchored to is not a list, let's create a list of a desired style. + else { + editor.execute( parentCommandName ); + editor.execute( 'listStyles', { type } ); + } + + editor.editing.view.focus(); + } ); + + return button; + }; +} diff --git a/packages/ckeditor5-list/src/utils.js b/packages/ckeditor5-list/src/utils.js index e14d0b9eb2e..6b75110c2c4 100644 --- a/packages/ckeditor5-list/src/utils.js +++ b/packages/ckeditor5-list/src/utils.js @@ -207,7 +207,6 @@ export function positionAfterUiElements( viewPosition ) { * @param {Boolean} [options.sameIndent=false] Whether the sought sibling should have the same indentation. * @param {Boolean} [options.smallerIndent=false] Whether the sought sibling should have a smaller indentation. * @param {Number} [options.listIndent] The reference indentation. - * @param {'forward'|'backward'} [options.direction='backward'] Walking direction. * @returns {module:engine/model/item~Item|null} */ export function getSiblingListItem( modelItem, options ) { @@ -224,11 +223,7 @@ export function getSiblingListItem( modelItem, options ) { return item; } - if ( options.direction === 'forward' ) { - item = item.nextSibling; - } else { - item = item.previousSibling; - } + item = item.previousSibling; } return null; diff --git a/packages/ckeditor5-list/tests/liststylesui.js b/packages/ckeditor5-list/tests/liststylesui.js index d54b7cef704..a7afacaaea4 100644 --- a/packages/ckeditor5-list/tests/liststylesui.js +++ b/packages/ckeditor5-list/tests/liststylesui.js @@ -3,18 +3,443 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/* globals document */ + +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; +import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph'; +import BlockQuote from '@ckeditor/ckeditor5-block-quote/src/blockquote'; +import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview'; +import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import ListStyles from '../src/liststyles'; import ListStylesUI from '../src/liststylesui'; +import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; + +import bulletedListIcon from '../theme/icons/bulletedlist.svg'; +import numberedListIcon from '../theme/icons/numberedlist.svg'; + +import listStyleDiscIcon from '../theme/icons/liststyledisc.svg'; +import listStyleCircleIcon from '../theme/icons/liststylecircle.svg'; +import listStyleSquareIcon from '../theme/icons/liststylesquare.svg'; +import listStyleDecimalIcon from '../theme/icons/liststyledecimal.svg'; +import listStyleDecimalWithLeadingZeroIcon from '../theme/icons/liststyledecimalleadingzero.svg'; +import listStyleLowerRomanIcon from '../theme/icons/liststylelowerroman.svg'; +import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg'; +import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg'; +import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg'; describe( 'ListStylesUI', () => { + let editorElement, editor, model, listStylesCommand; + + beforeEach( () => { + editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, BlockQuote, ListStyles ] } ) + .then( newEditor => { + editor = newEditor; + model = editor.model; + + listStylesCommand = editor.commands.get( 'listStyles' ); + } ); + } ); + + afterEach( () => { + editorElement.remove(); + + return editor.destroy(); + } ); + it( 'should be named', () => { expect( ListStylesUI.pluginName ).to.equal( 'ListStylesUI' ); } ); + it( 'should be loaded', () => { + expect( editor.plugins.get( ListStylesUI ) ).to.be.instanceOf( ListStylesUI ); + } ); + describe( 'init()', () => { - it( 'returns null', () => { - const plugin = new ListStylesUI( {} ); + describe( 'bulleted list dropdown', () => { + let bulletedListCommand, bulletedListDropdown; + + beforeEach( () => { + bulletedListCommand = editor.commands.get( 'bulletedList' ); + bulletedListDropdown = editor.ui.componentFactory.create( 'bulletedList' ); + } ); + + it( 'should registered as "bulletedList" in the component factory', () => { + expect( bulletedListDropdown ).to.be.instanceOf( DropdownView ); + } ); + + it( 'should have #isEnabled bound to the "bulletedList" command state', () => { + expect( bulletedListDropdown.isEnabled ).to.be.true; + + bulletedListCommand.isEnabled = true; + expect( bulletedListDropdown.isEnabled ).to.be.true; + + bulletedListCommand.isEnabled = false; + expect( bulletedListDropdown.isEnabled ).to.be.false; + } ); + + it( 'should have a specific CSS class', () => { + expect( bulletedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' ); + } ); + + describe( 'main split button', () => { + let mainButtonView; + + beforeEach( () => { + mainButtonView = bulletedListDropdown.buttonView; + } ); + + it( 'should have a #label', () => { + expect( mainButtonView.label ).to.equal( 'Bulleted List' ); + } ); + + it( 'should have an #icon', () => { + expect( mainButtonView.icon ).to.equal( bulletedListIcon ); + } ); + + it( 'should have a #tooltip based on a label', () => { + expect( mainButtonView.tooltip ).to.be.true; + } ); + + it( 'should be toggleable', () => { + expect( mainButtonView.isToggleable ).to.be.true; + } ); + + it( 'should have the #isOn state bound to the value of the "bulletedList" command', () => { + expect( mainButtonView.isOn ).to.be.false; + + bulletedListCommand.value = 'foo'; + expect( mainButtonView.isOn ).to.be.true; + + bulletedListCommand.value = null; + expect( mainButtonView.isOn ).to.be.false; + } ); + + it( 'should execute the "bulletedList" command and focus the editing view when clicked', () => { + sinon.spy( editor, 'execute' ); + sinon.spy( editor.editing.view, 'focus' ); + + mainButtonView.fire( 'execute' ); + sinon.assert.calledWithExactly( editor.execute, 'bulletedList' ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + } ); + + describe( 'toolbar with style buttons', () => { + let toolbarView; + + beforeEach( () => { + toolbarView = bulletedListDropdown.toolbarView; + } ); + + it( 'should be in the dropdown panel', () => { + expect( bulletedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView ); + } ); + + it( 'should have a proper ARIA label', () => { + expect( toolbarView.ariaLabel ).to.equal( 'Bulleted list styles toolbar' ); + } ); + + it( 'should bring the "disc" list style button', () => { + const buttonView = toolbarView.items.get( 0 ); + + expect( buttonView.label ).to.equal( 'Toggle the disc list style' ); + expect( buttonView.tooltip ).to.equal( 'Disc' ); + expect( buttonView.icon ).to.equal( listStyleDiscIcon ); + } ); + + it( 'should bring the "circle" list style button', () => { + const buttonView = toolbarView.items.get( 1 ); + + expect( buttonView.label ).to.equal( 'Toggle the circle list style' ); + expect( buttonView.tooltip ).to.equal( 'Circle' ); + expect( buttonView.icon ).to.equal( listStyleCircleIcon ); + } ); + + it( 'should bring the "square" list style button', () => { + const buttonView = toolbarView.items.get( 2 ); + + expect( buttonView.label ).to.equal( 'Toggle the square list style' ); + expect( buttonView.tooltip ).to.equal( 'Square' ); + expect( buttonView.icon ).to.equal( listStyleSquareIcon ); + } ); + + describe( 'style button', () => { + let styleButtonView; + + beforeEach( () => { + // "circle" + styleButtonView = toolbarView.items.get( 1 ); + + sinon.spy( editor, 'execute' ); + sinon.spy( editor.editing.view, 'focus' ); + } ); + + it( 'should be instances of ButtonView', () => { + expect( styleButtonView ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should change its #isOn state when the value of the "listStylesCommand" command changes', () => { + expect( styleButtonView.isOn ).to.be.false; + + listStylesCommand.value = 'foo'; + expect( styleButtonView.isOn ).to.be.false; + + listStylesCommand.value = 'circle'; + expect( styleButtonView.isOn ).to.be.true; + + listStylesCommand.value = null; + expect( styleButtonView.isOn ).to.be.false; + } ); + + it( 'should apply the new style if none was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'circle' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should apply the new style if a different one was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'circle' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should remove (toggle) the style if the same style was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'default' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should execute the "bulletedList" command and apply the style if selection was not anchored in a list', () => { + setData( model, 'foo[]' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'circle' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + } ); + } ); + } ); + + describe( 'numbered list dropdown', () => { + let numberedListCommand, numberedListDropdown; + + beforeEach( () => { + numberedListCommand = editor.commands.get( 'numberedList' ); + numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' ); + } ); + + it( 'should registered as "numberedList" in the component factory', () => { + expect( numberedListDropdown ).to.be.instanceOf( DropdownView ); + } ); + + it( 'should have #isEnabled bound to the "numberedList" command state', () => { + expect( numberedListDropdown.isEnabled ).to.be.true; + + numberedListCommand.isEnabled = true; + expect( numberedListDropdown.isEnabled ).to.be.true; + + numberedListCommand.isEnabled = false; + expect( numberedListDropdown.isEnabled ).to.be.false; + } ); + + it( 'should have a specific CSS class', () => { + expect( numberedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' ); + } ); + + describe( 'main split button', () => { + let mainButtonView; + + beforeEach( () => { + mainButtonView = numberedListDropdown.buttonView; + } ); + + it( 'should have a #label', () => { + expect( mainButtonView.label ).to.equal( 'Numbered List' ); + } ); + + it( 'should have an #icon', () => { + expect( mainButtonView.icon ).to.equal( numberedListIcon ); + } ); + + it( 'should have a #tooltip based on a label', () => { + expect( mainButtonView.tooltip ).to.be.true; + } ); + + it( 'should be toggleable', () => { + expect( mainButtonView.isToggleable ).to.be.true; + } ); + + it( 'should have the #isOn state bound to the value of the "numberedList" command', () => { + expect( mainButtonView.isOn ).to.be.false; + + numberedListCommand.value = 'foo'; + expect( mainButtonView.isOn ).to.be.true; + + numberedListCommand.value = null; + expect( mainButtonView.isOn ).to.be.false; + } ); + + it( 'should execute the "numberedList" command and focus the editing view when clicked', () => { + sinon.spy( editor, 'execute' ); + sinon.spy( editor.editing.view, 'focus' ); + + mainButtonView.fire( 'execute' ); + sinon.assert.calledWithExactly( editor.execute, 'numberedList' ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + } ); + + describe( 'toolbar with style buttons', () => { + let toolbarView; + + beforeEach( () => { + toolbarView = numberedListDropdown.toolbarView; + } ); + + it( 'should be in the dropdown panel', () => { + expect( numberedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView ); + } ); + + it( 'should have a proper ARIA label', () => { + expect( toolbarView.ariaLabel ).to.equal( 'Numbered list styles toolbar' ); + } ); + + it( 'should bring the "decimal" list style button', () => { + const buttonView = toolbarView.items.get( 0 ); + + expect( buttonView.label ).to.equal( 'Toggle the decimal list style' ); + expect( buttonView.tooltip ).to.equal( 'Decimal' ); + expect( buttonView.icon ).to.equal( listStyleDecimalIcon ); + } ); + + it( 'should bring the "decimal-leading-zero" list style button', () => { + const buttonView = toolbarView.items.get( 1 ); + + expect( buttonView.label ).to.equal( 'Toggle the decimal with leading zero list style' ); + expect( buttonView.tooltip ).to.equal( 'Decimal with leading zero' ); + expect( buttonView.icon ).to.equal( listStyleDecimalWithLeadingZeroIcon ); + } ); + + it( 'should bring the "lower-roman" list style button', () => { + const buttonView = toolbarView.items.get( 2 ); + + expect( buttonView.label ).to.equal( 'Toggle the lower–roman list style' ); + expect( buttonView.tooltip ).to.equal( 'Lower–roman' ); + expect( buttonView.icon ).to.equal( listStyleLowerRomanIcon ); + } ); + + it( 'should bring the "upper-roman" list style button', () => { + const buttonView = toolbarView.items.get( 3 ); + + expect( buttonView.label ).to.equal( 'Toggle the upper–roman list style' ); + expect( buttonView.tooltip ).to.equal( 'Upper-roman' ); + expect( buttonView.icon ).to.equal( listStyleUpperRomanIcon ); + } ); + + it( 'should bring the "lower–latin" list style button', () => { + const buttonView = toolbarView.items.get( 4 ); + + expect( buttonView.label ).to.equal( 'Toggle the lower–latin list style' ); + expect( buttonView.tooltip ).to.equal( 'Lower-latin' ); + expect( buttonView.icon ).to.equal( listStyleLowerLatinIcon ); + } ); + + it( 'should bring the "upper–latin" list style button', () => { + const buttonView = toolbarView.items.get( 5 ); + + expect( buttonView.label ).to.equal( 'Toggle the upper–latin list style' ); + expect( buttonView.tooltip ).to.equal( 'Upper-latin' ); + expect( buttonView.icon ).to.equal( listStyleUpperLatinIcon ); + } ); + + describe( 'style button', () => { + let styleButtonView; + + beforeEach( () => { + // "decimal-leading-zero"" + styleButtonView = toolbarView.items.get( 1 ); + + sinon.spy( editor, 'execute' ); + sinon.spy( editor.editing.view, 'focus' ); + } ); + + it( 'should be instances of ButtonView', () => { + expect( styleButtonView ).to.be.instanceOf( ButtonView ); + } ); + + it( 'should change its #isOn state when the value of the "listStylesCommand" command changes', () => { + expect( styleButtonView.isOn ).to.be.false; + + listStylesCommand.value = 'foo'; + expect( styleButtonView.isOn ).to.be.false; + + listStylesCommand.value = 'decimal-leading-zero'; + expect( styleButtonView.isOn ).to.be.true; + + listStylesCommand.value = null; + expect( styleButtonView.isOn ).to.be.false; + } ); + + it( 'should apply the new style if none was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'decimal-leading-zero' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should apply the new style if a different one was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'decimal-leading-zero' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should remove (toggle) the style if the same style was set', () => { + setData( model, '[]foo' ); + + styleButtonView.fire( 'execute' ); + + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'default' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + + it( 'should execute the "numberedList" command and apply the style if selection was not anchored in a list', () => { + setData( model, 'foo[]' ); + + styleButtonView.fire( 'execute' ); - expect( plugin.init() ).to.equal( null ); + sinon.assert.calledWithExactly( editor.execute, 'listStyles', { type: 'decimal-leading-zero' } ); + sinon.assert.calledOnce( editor.editing.view.focus ); + sinon.assert.callOrder( editor.execute, editor.editing.view.focus ); + } ); + } ); + } ); } ); } ); } ); diff --git a/packages/ckeditor5-list/theme/icons/liststylecircle.svg b/packages/ckeditor5-list/theme/icons/liststylecircle.svg new file mode 100644 index 00000000000..13a23c93ceb --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststylecircle.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststyledecimal.svg b/packages/ckeditor5-list/theme/icons/liststyledecimal.svg new file mode 100644 index 00000000000..cdaf9d557fe --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststyledecimal.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg b/packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg new file mode 100644 index 00000000000..63dac91fea0 --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststyledisc.svg b/packages/ckeditor5-list/theme/icons/liststyledisc.svg new file mode 100644 index 00000000000..e4fc7e8ac6e --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststyledisc.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg b/packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg new file mode 100644 index 00000000000..756de456f2e --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststylelowerroman.svg b/packages/ckeditor5-list/theme/icons/liststylelowerroman.svg new file mode 100644 index 00000000000..dcc6e10b1fe --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststylelowerroman.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststylesquare.svg b/packages/ckeditor5-list/theme/icons/liststylesquare.svg new file mode 100644 index 00000000000..8c8b50ca579 --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststylesquare.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg b/packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg new file mode 100644 index 00000000000..b963bf6b9fc --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/icons/liststyleupperroman.svg b/packages/ckeditor5-list/theme/icons/liststyleupperroman.svg new file mode 100644 index 00000000000..4c40f3e82d1 --- /dev/null +++ b/packages/ckeditor5-list/theme/icons/liststyleupperroman.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-list/theme/liststyles.css b/packages/ckeditor5-list/theme/liststyles.css new file mode 100644 index 00000000000..119de648756 --- /dev/null +++ b/packages/ckeditor5-list/theme/liststyles.css @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck.ck-list-styles-dropdown > .ck-dropdown__panel > .ck-toolbar > .ck-toolbar__items { + /* + * Use the benefits of the toolbar (e.g. out-of-the-box keyboard navigation) but make it look + * like panel with thumbnails (previews). + */ + display: grid; +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css new file mode 100644 index 00000000000..a9236105999 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +:root { + --ck-list-style-button-size: 44px; +} + +.ck.ck-list-styles-dropdown > .ck-dropdown__panel > .ck-toolbar { + background: none; + padding: 0; + + & > .ck-toolbar__items { + grid-template-columns: repeat( 3, auto ); + row-gap: var(--ck-spacing-medium); + column-gap: var(--ck-spacing-medium); + padding: var(--ck-spacing-medium); + + & .ck-button { + /* Make the button look like a thumbnail (the icon "takes it all"). */ + width: var(--ck-list-style-button-size); + height: var(--ck-list-style-button-size); + padding: 0; + + /* + * Buttons are aligned by the grid so disable default button margins to not collide with the + * gaps in the grid. + */ + margin: 0; + + /* + * Make sure the button border (which is displayed on focus, BTW) does not steal pixels + * from the button dimensions and, as a result, decrease the size of the icon + * (which becomes blurry as it scales down). + */ + box-sizing: content-box; + + & .ck-icon { + width: var(--ck-list-style-button-size); + height: var(--ck-list-style-button-size); + } + } + } +} diff --git a/packages/ckeditor5-ui/src/componentfactory.js b/packages/ckeditor5-ui/src/componentfactory.js index fbc7da2198f..738a098bceb 100644 --- a/packages/ckeditor5-ui/src/componentfactory.js +++ b/packages/ckeditor5-ui/src/componentfactory.js @@ -77,20 +77,6 @@ export default class ComponentFactory { * @param {Function} callback The callback that returns the component. */ add( name, callback ) { - if ( this.has( name ) ) { - /** - * The item already exists in the component factory. - * - * @error componentfactory-item-exists - * @param {String} name The name of the component. - */ - throw new CKEditorError( - 'componentfactory-item-exists: The item already exists in the component factory.', - this, - { name } - ); - } - this._components.set( getNormalized( name ), { callback, originalName: name } ); } diff --git a/packages/ckeditor5-ui/tests/componentfactory.js b/packages/ckeditor5-ui/tests/componentfactory.js index 423cc18440a..4ef8a4ac805 100644 --- a/packages/ckeditor5-ui/tests/componentfactory.js +++ b/packages/ckeditor5-ui/tests/componentfactory.js @@ -39,26 +39,24 @@ describe( 'ComponentFactory', () => { } ); describe( 'add()', () => { - it( 'throws when trying to override already registered component', () => { - factory.add( 'foo', () => {} ); + it( 'does not normalize component names', () => { + factory.add( 'FoO', () => {} ); - expectToThrowCKEditorError( () => { - factory.add( 'foo', () => {} ); - }, /^componentfactory-item-exists/, editor ); + expect( Array.from( factory.names() ) ).to.have.members( [ 'FoO' ] ); } ); - it( 'throws when trying to override already registered component added with different case', () => { - factory.add( 'Foo', () => {} ); + it( 'should allow overriding already registered components', () => { + factory.add( 'foo', () => 'old' ); + factory.add( 'foo', () => 'new' ); - expectToThrowCKEditorError( () => { - factory.add( 'foo', () => {} ); - }, /^componentfactory-item-exists/, editor ); + expect( factory.create( 'foo' ) ).to.equal( 'new' ); } ); - it( 'does not normalize component names', () => { - factory.add( 'FoO', () => {} ); + it( 'should allow overriding already registered components (same name, different case)', () => { + factory.add( 'foo', () => 'old' ); + factory.add( 'Foo', () => 'new' ); - expect( Array.from( factory.names() ) ).to.have.members( [ 'FoO' ] ); + expect( factory.create( 'foo' ) ).to.equal( 'new' ); } ); } );