8
0
Quellcode durchsuchen

Merge pull request #7845 from ckeditor/i/7803-list-styles-ui

Internal (list): Created the list styles UI. Closes #7803.
Kamil Piechaczek vor 5 Jahren
Ursprung
Commit
87beb070e3

+ 21 - 1
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."
 }

+ 1 - 1
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",

+ 203 - 1
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;
+	};
+}

+ 428 - 3
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, '<listItem listType="bulleted" listIndent="0">[]foo</listItem>' );
+
+						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, '<listItem listType="bulleted" listStyle="square" listIndent="0">[]foo</listItem>' );
+
+						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, '<listItem listType="bulleted" listStyle="circle" listIndent="0">[]foo</listItem>' );
+
+						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, '<paragraph>foo[]</paragraph>' );
+
+						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, '<listItem listType="numbered" listIndent="0">[]foo</listItem>' );
+
+						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, '<listItem listType="numbered" listStyle="square" listIndent="0">[]foo</listItem>' );
+
+						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, '<listItem listType="numbered" listStyle="decimal-leading-zero" listIndent="0">[]foo</listItem>' );
+
+						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, '<paragraph>foo[]</paragraph>' );
+
+						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 );
+					} );
+				} );
+			} );
 		} );
 	} );
 } );

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylecircle.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm0-10a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0 1a2 2 0 1 0 0 4 2 2 0 0 0 0-4z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyledecimal.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M10.29 15V8.531H9.286c-.14.393-.4.736-.778 1.03-.378.295-.728.495-1.05.6v1.121a4.257 4.257 0 001.595-.936V15h1.235zm3.343 0v-1.235h-1.235V15h1.235zM11.3 24v-1.147H8.848c.064-.111.148-.226.252-.343.104-.117.351-.354.74-.712.39-.357.66-.631.81-.821.225-.288.39-.562.494-.824.104-.263.156-.539.156-.829 0-.51-.182-.936-.545-1.279-.363-.342-.863-.514-1.499-.514-.58 0-1.063.148-1.45.444-.387.296-.617.784-.69 1.463l1.23.124c.024-.36.112-.619.264-.774.153-.155.358-.233.616-.233.26 0 .465.074.613.222.148.148.222.36.222.635 0 .25-.085.501-.255.756-.126.185-.468.536-1.024 1.055-.692.641-1.155 1.156-1.389 1.544-.234.389-.375.8-.422 1.233H11.3zm2.333 0v-1.235h-1.235V24h1.235zM9.204 34.11c.615 0 1.129-.2 1.542-.598.413-.398.62-.88.62-1.446 0-.39-.11-.722-.332-.997a1.5 1.5 0 00-.886-.532c.619-.337.928-.788.928-1.353 0-.399-.151-.756-.453-1.073-.366-.386-.852-.58-1.459-.58-.354 0-.674.067-.96.2a1.617 1.617 0 00-.668.55c-.16.232-.28.544-.358.933l1.138.194c.032-.282.123-.495.272-.642.15-.146.33-.22.54-.22.215 0 .386.065.515.194s.193.302.193.518c0 .255-.087.46-.263.613-.176.154-.43.227-.765.218l-.136 1.006c.22-.061.409-.092.567-.092.24 0 .444.09.61.272.168.182.251.428.251.739 0 .328-.087.589-.261.782a.833.833 0 01-.644.29.841.841 0 01-.607-.242c-.167-.16-.27-.394-.307-.698l-1.196.145c.062.542.285.98.668 1.316.384.335.868.503 1.45.503zm4.43-.11v-1.235h-1.236V34h1.235z"/></g></svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg


+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyledisc.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11 27a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6zm0-9a3 3 0 1 1 0 6 3 3 0 0 1 0-6z"/></g></svg>

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg


+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylelowerroman.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.88 8.7V7.558h-1.234V8.7h1.234zm0 5.3V9.333h-1.234V14h1.234zm2.5 0v-1.235h-1.234V14h1.235zM9.63 18.7v-1.142H8.395V18.7H9.63zm0 5.3v-4.667H8.395V24H9.63zm2.5-5.3v-1.142h-1.234V18.7h1.235zm0 5.3v-4.667h-1.234V24h1.235zm2.501 0v-1.235h-1.235V24h1.235zM7.38 28.7v-1.142H6.145V28.7H7.38zm0 5.3v-4.667H6.145V34H7.38zm2.5-5.3v-1.142H8.646V28.7H9.88zm0 5.3v-4.667H8.646V34H9.88zm2.5-5.3v-1.142h-1.234V28.7h1.235zm0 5.3v-4.667h-1.234V34h1.235zm2.501 0v-1.235h-1.235V34h1.235z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststylesquare.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M14 27v6H8v-6h6zm0-9v6H8v-6h6zm0-9v6H8V9h6z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M7.88 15l.532-1.463h2.575L11.549 15h1.415l-2.58-6.442H9.01L6.5 15h1.38zm2.69-2.549H8.811l.87-2.39.887 2.39zM14.88 15v-1.235h-1.234V15h1.234zM9.352 25c.83-.006 1.352-.02 1.569-.044.346-.038.636-.14.872-.305.236-.166.422-.387.558-.664.137-.277.205-.562.205-.855 0-.372-.106-.695-.317-.97-.21-.276-.512-.471-.905-.585.278-.126.499-.315.661-.567a1.5 1.5 0 00.244-.83c0-.28-.066-.53-.197-.754a1.654 1.654 0 00-.495-.539 1.676 1.676 0 00-.672-.266c-.25-.042-.63-.063-1.14-.063H7.158V25h2.193zm.142-3.88H8.46v-1.49h.747c.612 0 .983.007 1.112.022.217.026.38.102.49.226.11.125.165.287.165.486a.68.68 0 01-.192.503.86.86 0 01-.525.23 11.47 11.47 0 01-.944.023h.18zm.17 2.795H8.46v-1.723h1.05c.592 0 .977.03 1.154.092.177.062.313.16.406.295a.84.84 0 01.14.492c0 .228-.06.41-.181.547a.806.806 0 01-.473.257c-.126.026-.423.04-.892.04zM14.88 25v-1.235h-1.234V25h1.234zM9.862 34.11c.691 0 1.262-.17 1.711-.512.45-.341.772-.864.965-1.567l-1.261-.4c-.109.472-.287.818-.536 1.037-.25.22-.547.33-.892.33-.47 0-.85-.173-1.143-.519-.293-.345-.44-.925-.44-1.74 0-.767.15-1.322.447-1.665.297-.343.684-.514 1.162-.514.346 0 .64.096.881.29.242.193.4.457.477.79l1.288-.307c-.147-.516-.367-.911-.66-1.187-.492-.465-1.132-.698-1.92-.698-.902 0-1.63.296-2.184.89-.554.593-.83 1.426-.83 2.498 0 1.014.275 1.813.825 2.397.551.585 1.254.877 2.11.877zM14.88 34v-1.235h-1.234V34h1.234z"/></g></svg>

+ 1 - 0
packages/ckeditor5-list/theme/icons/liststyleupperroman.svg

@@ -0,0 +1 @@
+<svg viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg"><g><path d="M35 29a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17zm0-9a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H18a1 1 0 0 1-1-1v-1a1 1 0 0 1 1-1h17z" fill-opacity=".163"/><path d="M11.916 15V8.558h-1.301V15h1.3zm2.465 0v-1.235h-1.235V15h1.235zM9.665 25v-6.442h-1.3V25h1.3zm2.5 0v-6.442h-1.3V25h1.3zm2.466 0v-1.235h-1.235V25h1.235zM7.415 34v-6.442h-1.3V34h1.3zm2.5 0v-6.442h-1.3V34h1.3zm2.501 0v-6.442h-1.3V34h1.3zm2.465 0v-1.235h-1.235V34h1.235z"/></g></svg>

+ 12 - 0
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;
+}

+ 45 - 0
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);
+			}
+		}
+	}
+}

+ 0 - 14
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 } );
 	}
 

+ 11 - 13
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' );
 		} );
 	} );
 

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.