8
0
Эх сурвалжийг харах

Merge branch 'master' into i/7874

Aleksander Nowodzinski 5 жил өмнө
parent
commit
d08e9448dd
39 өөрчлөгдсөн 3421 нэмэгдсэн , 29 устгасан
  1. 20 0
      packages/ckeditor5-list/docs/_snippets/features/lists-basic.html
  2. 57 0
      packages/ckeditor5-list/docs/_snippets/features/lists-basic.js
  3. 0 0
      packages/ckeditor5-list/docs/_snippets/features/lists-source.html
  4. 12 0
      packages/ckeditor5-list/docs/_snippets/features/lists-source.js
  5. 17 0
      packages/ckeditor5-list/docs/_snippets/features/lists-style.html
  6. 58 0
      packages/ckeditor5-list/docs/_snippets/features/lists-style.js
  7. 93 0
      packages/ckeditor5-list/docs/features/lists.md
  8. 4 0
      packages/ckeditor5-list/docs/features/todo-lists.md
  9. 21 1
      packages/ckeditor5-list/lang/contexts.json
  10. 2 0
      packages/ckeditor5-list/package.json
  11. 6 0
      packages/ckeditor5-list/src/converters.js
  12. 12 0
      packages/ckeditor5-list/src/indentcommand.js
  13. 11 0
      packages/ckeditor5-list/src/listcommand.js
  14. 36 0
      packages/ckeditor5-list/src/liststyle.js
  15. 197 0
      packages/ckeditor5-list/src/liststylecommand.js
  16. 471 0
      packages/ckeditor5-list/src/liststyleediting.js
  17. 230 0
      packages/ckeditor5-list/src/liststyleui.js
  18. 21 1
      packages/ckeditor5-list/tests/indentcommand.js
  19. 29 0
      packages/ckeditor5-list/tests/listcommand.js
  20. 18 0
      packages/ckeditor5-list/tests/liststyle.js
  21. 391 0
      packages/ckeditor5-list/tests/liststylecommand.js
  22. 896 0
      packages/ckeditor5-list/tests/liststyleediting.js
  23. 480 0
      packages/ckeditor5-list/tests/liststyleui.js
  24. 182 0
      packages/ckeditor5-list/tests/manual/list-style.html
  25. 65 0
      packages/ckeditor5-list/tests/manual/list-style.js
  26. 17 0
      packages/ckeditor5-list/tests/manual/list-style.md
  27. 1 0
      packages/ckeditor5-list/theme/icons/liststylecircle.svg
  28. 1 0
      packages/ckeditor5-list/theme/icons/liststyledecimal.svg
  29. 0 0
      packages/ckeditor5-list/theme/icons/liststyledecimalleadingzero.svg
  30. 1 0
      packages/ckeditor5-list/theme/icons/liststyledisc.svg
  31. 0 0
      packages/ckeditor5-list/theme/icons/liststylelowerlatin.svg
  32. 1 0
      packages/ckeditor5-list/theme/icons/liststylelowerroman.svg
  33. 1 0
      packages/ckeditor5-list/theme/icons/liststylesquare.svg
  34. 1 0
      packages/ckeditor5-list/theme/icons/liststyleupperlatin.svg
  35. 1 0
      packages/ckeditor5-list/theme/icons/liststyleupperroman.svg
  36. 12 0
      packages/ckeditor5-list/theme/liststyles.css
  37. 45 0
      packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css
  38. 0 14
      packages/ckeditor5-ui/src/componentfactory.js
  39. 11 13
      packages/ckeditor5-ui/tests/componentfactory.js

+ 20 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-basic.html

@@ -0,0 +1,20 @@
+<div id="snippet-lists-basic">
+    <h2>Unordered list</h2>
+    <p>Such a list can represent items where the order is not important, e.g. a list of ingredients required for preparing a dish or a drink.</p>
+    <p>Ingredients required for making a coffee:</p>
+    <ul>
+        <li>15g coffee ground</li>
+        <li>a cup</li>
+        <li>water</li>
+    </ul>
+    <h2>Ordered list</h2>
+    <p>The ordered list can be used if the order of the items matters, e.g. when describing an instruction. The order of steps that must be done is important.</p>
+    <p>How to prepare the coffee?</p>
+    <ol>
+        <li>Gather the ingredients</li>
+        <li>Put 15g of the coffee ground into the cup</li>
+        <li>Boil 200ml of water</li>
+        <li>Pour water into a cup</li>
+        <li>Optional: add milk as you wish</li>
+    </ol>
+</div>

+ 57 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-basic.js

@@ -0,0 +1,57 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console, ClassicEditor */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-lists-basic' ), {
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'|',
+				'outdent',
+				'indent',
+				'|',
+				'link',
+				'imageUpload',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorBasic = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 0 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-source.html


+ 12 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-source.js

@@ -0,0 +1,12 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-build-classic/src/ckeditor';
+import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle';
+
+window.ClassicEditor = ClassicEditor;
+window.ListStyle = ListStyle;

+ 17 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-style.html

@@ -0,0 +1,17 @@
+<div id="snippet-lists-styles">
+    <p>Let's use the coffee recipe from the demo in section <a href="#demo">Ordered and unordered lists</a> and use the list styles feature.</p>
+    <p>Ingredients required for making a coffee:</p>
+    <ul style="list-style-type:circle;">
+        <li>15g coffee ground</li>
+        <li>a cup</li>
+        <li>water</li>
+    </ul>
+    <p>How to prepare the coffee?</p>
+    <ol style="list-style-type:decimal-leading-zero;">
+        <li>Gather the ingredients</li>
+        <li>Put 15g of the coffee ground into the cup</li>
+        <li>Boil 200ml of water</li>
+        <li>Pour water into a cup</li>
+        <li>Optional: add milk as you wish</li>
+    </ol>
+</div>

+ 58 - 0
packages/ckeditor5-list/docs/_snippets/features/lists-style.js

@@ -0,0 +1,58 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals window, document, console, ClassicEditor, ListStyle */
+
+import { CS_CONFIG } from '@ckeditor/ckeditor5-cloud-services/tests/_utils/cloud-services-config';
+
+ClassicEditor
+	.create( document.querySelector( '#snippet-lists-styles' ), {
+		extraPlugins: [ ListStyle ],
+		toolbar: {
+			items: [
+				'heading',
+				'|',
+				'bold',
+				'italic',
+				'bulletedList',
+				'numberedList',
+				'|',
+				'outdent',
+				'indent',
+				'|',
+				'link',
+				'imageUpload',
+				'insertTable',
+				'|',
+				'undo',
+				'redo'
+			],
+			viewportTopOffset: window.getViewportTopOffsetConfig()
+		},
+		image: {
+			styles: [
+				'full',
+				'alignLeft',
+				'alignRight'
+			],
+			toolbar: [
+				'imageStyle:alignLeft',
+				'imageStyle:full',
+				'imageStyle:alignRight',
+				'|',
+				'imageTextAlternative'
+			]
+		},
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ]
+		},
+		cloudServices: CS_CONFIG
+	} )
+	.then( editor => {
+		window.editorStyles = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 93 - 0
packages/ckeditor5-list/docs/features/lists.md

@@ -0,0 +1,93 @@
+---
+category: features
+---
+
+# Lists
+
+The {@link module:list/list~List list} feature allows creating ordered and unordered lists in the editor.
+
+<info-box info>
+	The feature is enabled by default in all CKEditor 5 WYSIWYG editor builds.
+</info-box>
+
+{@snippet features/lists-source}
+
+## Ordered and unordered lists
+
+Use the editor below to see the list feature plugin in action.
+
+### Demo
+
+{@snippet features/lists-basic}
+
+## List styles
+
+The {@link module:list/liststyle~ListStyle list styles} feature allows customizing the list's marker.
+
+### Demo
+
+Use the editor below to see the list styles feature plugin in action.
+
+{@snippet features/lists-style}
+
+### Installation
+
+To add this feature to your editor, install the [`@ckeditor/ckeditor5-list`](https://www.npmjs.com/package/@ckeditor/ckeditor5-list) package:
+
+```bash
+npm install --save @ckeditor/ckeditor5-list
+```
+
+Then add the `ListStyle` plugin to your plugin list and the toolbar configuration:
+
+```js
+import ListStyle from '@ckeditor/ckeditor5-list/src/liststyle';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ ListStyle, ... ],
+		toolbar: [ 'bulletedList', 'numberedList', ... ],
+	} )
+	.then( ... )
+	.catch( ... );
+```
+
+<info-box info>
+	Read more about {@link builds/guides/integration/installing-plugins installing plugins}.
+</info-box>
+
+<info-box warning>
+	The {@link module:list/liststyle~ListStyle} feature overrides UI button implementations from the {@link module:list/listui~ListUI}.
+</info-box>
+
+## To-do list
+
+You can read more about the feature in the {@link features/todo-lists To-do lists} feature guide.
+
+## Common API
+
+The {@link module:list/list~List} plugin registers:
+
+* The {@link module:list/listcommand~ListCommand `'numberedList'`} command.
+* The {@link module:list/listcommand~ListCommand `'bulletedList'`} command.
+* The {@link module:list/indentcommand~IndentCommand `'indentList'`} command.
+* The {@link module:list/indentcommand~IndentCommand `'outdentList'`} command.
+* The `'numberedList'` ui button.
+* The `'bulletedList'` ui button.
+
+The {@link module:list/liststyle~ListStyle} plugin registers:
+
+* The {@link module:list/liststylecommand~ListStyleCommand `'listStyle'`} command that accepts a `type` of a list style to set.
+    ```js
+    editor.execute( 'listStyle', { type: 'decimal' } );
+    ```
+    The available types are:
+
+    * For bulleted lists: `'disc'`, `'circle'`, and `'square'`.
+    * For numbered lists: `'decimal'`, `'decimal-leading-zero'`, `'lower-roman'`, `'upper-roman'`, `'lower-latin'`, and `'upper-latin'`.
+* The `'numberedList'` ui split button (it overrides UI button registered by the `List` plguin.
+* The `'bulletedList'` ui split button (it overrides UI button registered by the `List` plguin.
+
+## Contribute
+
+The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-list.

+ 4 - 0
packages/ckeditor5-list/docs/features/todo-lists.md

@@ -88,6 +88,10 @@ From the technical point of view, to-do lists are built on top of the {@link mod
 <listItem listType="todo" todoListChecked="true">Bar</listItem>
 ```
 
+## Ordered and unordered lists
+
+You can read more about those features in the {@link features/lists Lists} feature guide.
+
 ## Contribute
 
 The source code of the feature is available on GitHub in https://github.com/ckeditor/ckeditor5/tree/master/packages/ckeditor5-list.

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

+ 2 - 0
packages/ckeditor5-list/package.json

@@ -20,6 +20,7 @@
     "@ckeditor/ckeditor5-basic-styles": "^21.0.0",
     "@ckeditor/ckeditor5-block-quote": "^21.0.0",
     "@ckeditor/ckeditor5-clipboard": "^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",
@@ -27,6 +28,7 @@
     "@ckeditor/ckeditor5-highlight": "^21.0.0",
     "@ckeditor/ckeditor5-indent": "^21.0.0",
     "@ckeditor/ckeditor5-link": "^21.0.0",
+    "@ckeditor/ckeditor5-remove-format": "^21.0.0",
     "@ckeditor/ckeditor5-table": "^21.0.0",
     "@ckeditor/ckeditor5-typing": "^21.0.0",
     "@ckeditor/ckeditor5-undo": "^21.0.0"

+ 6 - 0
packages/ckeditor5-list/src/converters.js

@@ -611,6 +611,12 @@ export function modelChangePostFixer( model, writer ) {
 					applied = true;
 				}
 
+				if ( item.hasAttribute( 'listStyle' ) ) {
+					writer.removeAttribute( 'listStyle', item );
+
+					applied = true;
+				}
+
 				for ( const innerItem of Array.from( model.createRangeIn( item ) ).filter( e => e.item.is( 'element', 'listItem' ) ) ) {
 					_addListToFix( innerItem.previousPosition );
 				}

+ 12 - 0
packages/ckeditor5-list/src/indentcommand.js

@@ -47,6 +47,7 @@ export default class IndentCommand extends Command {
 	 * Indents or outdents (depending on the {@link #constructor}'s `indentDirection` parameter) selected list items.
 	 *
 	 * @fires execute
+	 * @fires _executeCleanup
 	 */
 	execute() {
 		const model = this.editor.model;
@@ -90,6 +91,17 @@ export default class IndentCommand extends Command {
 					writer.setAttribute( 'listIndent', indent, item );
 				}
 			}
+
+			/**
+			 * Event fired by the {@link #execute} method.
+			 *
+			 * It allows to execute an action after executing the {@link ~IndentCommand#execute} method, e.g. adjusting
+			 * attributes of changed list items.
+			 *
+			 * @protected
+			 * @event _executeCleanup
+			 */
+			this.fire( '_executeCleanup', itemsToChange );
 		} );
 	}
 

+ 11 - 0
packages/ckeditor5-list/src/listcommand.js

@@ -211,6 +211,17 @@ export default class ListCommand extends Command {
 					writer.setAttribute( 'listType', this.type, element );
 				}
 			}
+
+			/**
+			 * Event fired by the {@link #execute} method.
+			 *
+			 * It allows to execute an action after executing the {@link ~ListCommand#execute} method, e.g. adjusting
+			 * attributes of changed blocks.
+			 *
+			 * @protected
+			 * @event _executeCleanup
+			 */
+			this.fire( '_executeCleanup', blocks );
 		} );
 	}
 

+ 36 - 0
packages/ckeditor5-list/src/liststyle.js

@@ -0,0 +1,36 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyle
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ListStyleEditing from './liststyleediting';
+import ListStyleUI from './liststyleui';
+
+/**
+ * The list styles feature.
+ *
+ * This is a "glue" plugin that loads the {@link module:list/liststyleediting~ListStyleEditing list styles editing feature}
+ * and the {@link module:list/liststyleui~ListStyleUI list styles UI feature}.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ListStyle extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListStyleEditing, ListStyleUI ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyle';
+	}
+}

+ 197 - 0
packages/ckeditor5-list/src/liststylecommand.js

@@ -0,0 +1,197 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststylecommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+import TreeWalker from '@ckeditor/ckeditor5-engine/src/model/treewalker';
+
+/**
+ * The list style command. It is used by the {@link module:list/liststyle~ListStyle list styles feature}.
+ *
+ * @extends module:core/command~Command
+ */
+export default class ListStyleCommand extends Command {
+	/**
+	 * Creates an instance of the command.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor The editor instance.
+	 * @param {String} defaultType The list type that will be used by default if the value was not specified during
+	 * the command execution.
+	 */
+	constructor( editor, defaultType ) {
+		super( editor );
+
+		/**
+		 * The default type of the list style.
+		 *
+		 * @protected
+		 * @member {String}
+		 */
+		this._defaultType = defaultType;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		this.value = this._getValue();
+		this.isEnabled = this._checkEnabled();
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @param {Object} options
+	 * @param {String|null} options.type The type of the list styles, e.g. 'disc' or 'square'. If `null` specified, the default
+	 * style will be applied.
+	 * @protected
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const document = model.document;
+
+		// For all selected blocks find all list items that are being selected
+		// and update the `listStyle` attribute in those lists.
+		let listItems = [ ...document.selection.getSelectedBlocks() ]
+			.filter( element => element.is( 'element', 'listItem' ) )
+			.map( element => {
+				const position = model.change( writer => writer.createPositionAt( element, 0 ) );
+
+				return [
+					...getSiblingNodes( position, 'backward' ),
+					...getSiblingNodes( position, 'forward' )
+				];
+			} )
+			.flat();
+
+		// Since `getSelectedBlocks()` can return items that belong to the same list, and
+		// `getSiblingNodes()` returns the entire list, we need to remove duplicated items.
+		listItems = [ ...new Set( listItems ) ];
+
+		if ( !listItems.length ) {
+			return;
+		}
+
+		model.change( writer => {
+			for ( const item of listItems ) {
+				writer.setAttribute( 'listStyle', options.type || this._defaultType, item );
+			}
+		} );
+	}
+
+	/**
+	 * Checks the command's {@link #value}.
+	 *
+	 * @private
+	 * @returns {String|null} The current value.
+	 */
+	_getValue() {
+		const listItem = this.editor.model.document.selection.getFirstPosition().parent;
+
+		if ( listItem && listItem.is( 'element', 'listItem' ) ) {
+			return listItem.getAttribute( 'listStyle' );
+		}
+
+		return null;
+	}
+
+	/**
+	 * Checks whether the command can be enabled in the current context.
+	 *
+	 * @private
+	 * @returns {Boolean} Whether the command should be enabled.
+	 */
+	_checkEnabled() {
+		const editor = this.editor;
+
+		const numberedList = editor.commands.get( 'numberedList' );
+		const bulletedList = editor.commands.get( 'bulletedList' );
+
+		return numberedList.isEnabled || bulletedList.isEnabled;
+	}
+}
+
+// Returns an array with all `listItem` elements that represents the same list.
+//
+// It means that values for `listIndent`, `listType`, and `listStyle` for all items
+// are equal.
+//
+// @param {module:engine/model/position~Position} position Starting position.
+// @param {'forward'|'backward'} direction Walking direction.
+// @returns {Array.<module:engine/model/element~Element>
+function getSiblingNodes( position, direction ) {
+	const items = [];
+	const listItem = position.parent;
+	const walkerOptions = {
+		ignoreElementEnd: true,
+		startPosition: position,
+		shallow: true,
+		direction
+	};
+	const limitIndent = listItem.getAttribute( 'listIndent' );
+	const nodes = [ ...new TreeWalker( walkerOptions ) ]
+		.filter( value => value.item.is( 'element' ) )
+		.map( value => value.item );
+
+	for ( const element of nodes ) {
+		// If found something else than `listItem`, we're out of the list scope.
+		if ( !element.is( 'element', 'listItem' ) ) {
+			break;
+		}
+
+		// If current parsed item has lower indent that element that the element that was a starting point,
+		// it means we left a nested list. Abort searching items.
+		//
+		// ■ List item 1.       [listIndent=0]
+		//     ○ List item 2.[] [listIndent=1], limitIndent = 1,
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Abort searching when leave nested list.
+		if ( element.getAttribute( 'listIndent' ) < limitIndent ) {
+			break;
+		}
+
+		// ■ List item 1.[]     [listIndent=0] limitIndent = 0,
+		//     ○ List item 2.   [listIndent=1]
+		//     ○ List item 3.   [listIndent=1]
+		// ■ List item 4.       [listIndent=0]
+		//
+		// Ignore nested lists.
+		if ( element.getAttribute( 'listIndent' ) > limitIndent ) {
+			continue;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// 1. List item 2.   [listType=numbered]
+		// 2.List item 3.    [listType=numbered]
+		//
+		// Abort searching when found a different kind of a list.
+		if ( element.getAttribute( 'listType' ) !== listItem.getAttribute( 'listType' ) ) {
+			break;
+		}
+
+		// ■ List item 1.[]  [listType=bulleted]
+		// ■ List item 2.    [listType=bulleted]
+		// ○ List item 3.    [listType=bulleted]
+		// ○ List item 4.    [listType=bulleted]
+		//
+		// Abort searching when found a different list style.
+		if ( element.getAttribute( 'listStyle' ) !== listItem.getAttribute( 'listStyle' ) ) {
+			break;
+		}
+
+		if ( direction === 'backward' ) {
+			items.unshift( element );
+		} else {
+			items.push( element );
+		}
+	}
+
+	return items;
+}

+ 471 - 0
packages/ckeditor5-list/src/liststyleediting.js

@@ -0,0 +1,471 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyleediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import ListEditing from './listediting';
+import ListStyleCommand from './liststylecommand';
+import { getSiblingListItem } from './utils';
+
+const DEFAULT_LIST_TYPE = 'default';
+
+/**
+ * The list styles engine feature.
+ *
+ * It sets value for the `listItem` attribute for the {@link module:list/list~List `<listItem>`} element that
+ * allows modifying list style type.
+ *
+ * It registers the `'listStyle'` command.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class ListStyleEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ ListEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyleEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const model = editor.model;
+
+		// Extend schema.
+		model.schema.extend( 'listItem', {
+			allowAttributes: [ 'listStyle' ]
+		} );
+
+		editor.commands.add( 'listStyle', new ListStyleCommand( editor, DEFAULT_LIST_TYPE ) );
+
+		// Fix list attributes when modifying their nesting levels (the `listIndent` attribute).
+		this.listenTo( editor.commands.get( 'indentList' ), '_executeCleanup', fixListAfterIndentListCommand( editor ) );
+		this.listenTo( editor.commands.get( 'outdentList' ), '_executeCleanup', fixListAfterOutdentListCommand( editor ) );
+
+		this.listenTo( editor.commands.get( 'bulletedList' ), '_executeCleanup', restoreDefaultListStyle( editor ) );
+		this.listenTo( editor.commands.get( 'numberedList' ), '_executeCleanup', restoreDefaultListStyle( editor ) );
+
+		// Register a post-fixer that ensures that the `listStyle` attribute is specified in each `listItem` element.
+		model.document.registerPostFixer( fixListStyleAttributeOnListItemElements( editor ) );
+
+		// Set up conversion.
+		editor.conversion.for( 'upcast' ).add( upcastListItemStyle() );
+		editor.conversion.for( 'downcast' ).add( downcastListStyleAttribute() );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	afterInit() {
+		const editor = this.editor;
+
+		// Enable post-fixer that removes the `listStyle` attribute from to-do list items only if the "TodoList" plugin is on.
+		// We need to registry the hook here since the `TodoList` plugin can be added after the `ListStyleEditing`.
+		if ( editor.commands.get( 'todoList' ) ) {
+			editor.model.document.registerPostFixer( removeListStyleAttributeFromTodoList( editor ) );
+		}
+	}
+}
+
+// Returns a converter that consumes the `style` attribute and search for `list-style-type` definition.
+// If not found, the `"default"` value will be used.
+//
+// @private
+// @returns {Function}
+function upcastListItemStyle() {
+	return dispatcher => {
+		dispatcher.on( 'element:li', ( evt, data, conversionApi ) => {
+			const listParent = data.viewItem.parent;
+			const listStyle = listParent.getStyle( 'list-style-type' ) || DEFAULT_LIST_TYPE;
+			const listItem = data.modelRange.start.nodeAfter;
+
+			conversionApi.writer.setAttribute( 'listStyle', listStyle, listItem );
+		}, { priority: 'low' } );
+	};
+}
+
+// Returns a converter that adds the `list-style-type` definition as a value for the `style` attribute.
+// The `"default"` value is removed and not present in the view/data.
+//
+// @private
+// @returns {Function}
+function downcastListStyleAttribute() {
+	return dispatcher => {
+		dispatcher.on( 'attribute:listStyle:listItem', ( evt, data, conversionApi ) => {
+			const viewWriter = conversionApi.writer;
+			const currentElement = data.item;
+			const listStyle = data.attributeNewValue;
+
+			const previousElement = getSiblingListItem( currentElement.previousSibling, {
+				sameIndent: true,
+				listIndent: currentElement.getAttribute( 'listIndent' ),
+				direction: 'backward'
+			} );
+
+			const viewItem = conversionApi.mapper.toViewElement( currentElement );
+
+			// Single item list.
+			if ( !previousElement ) {
+				setListStyle( viewWriter, listStyle, viewItem.parent );
+			} else if ( !areRepresentingSameList( previousElement, currentElement ) ) {
+				viewWriter.breakContainer( viewWriter.createPositionBefore( viewItem ) );
+				viewWriter.breakContainer( viewWriter.createPositionAfter( viewItem ) );
+
+				setListStyle( viewWriter, listStyle, viewItem.parent );
+			}
+		}, { priority: 'low' } );
+	};
+
+	// Checks whether specified list items belong to the same list.
+	//
+	// @param {module:engine/model/element~Element} listItem1 The first list item to check.
+	// @param {module:engine/model/element~Element} listItem2 The second list item to check.
+	// @returns {Boolean}
+	function areRepresentingSameList( listItem1, listItem2 ) {
+		return listItem1.getAttribute( 'listType' ) === listItem2.getAttribute( 'listType' ) &&
+			listItem1.getAttribute( 'listIndent' ) === listItem2.getAttribute( 'listIndent' ) &&
+			listItem1.getAttribute( 'listStyle' ) === listItem2.getAttribute( 'listStyle' );
+	}
+
+	// Updates or removes the `list-style-type` from the `element`.
+	//
+	// @param {module:engine/view/downcastwriter~DowncastWriter} writer
+	// @param {String} listStyle
+	// @param {module:engine/view/element~Element} element
+	function setListStyle( writer, listStyle, element ) {
+		if ( listStyle && listStyle !== DEFAULT_LIST_TYPE ) {
+			writer.setStyle( 'list-style-type', listStyle, element );
+		} else {
+			writer.removeStyle( 'list-style-type', element );
+		}
+	}
+}
+
+// When indenting list, nested list should clear its value for the `listStyle` attribute or inherit from nested lists.
+//
+// ■ List item 1.
+// ■ List item 2.[]
+// ■ List item 3.
+// editor.execute( 'indentList' );
+//
+// ■ List item 1.
+//     ○ List item 2.[]
+// ■ List item 3.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListAfterIndentListCommand( editor ) {
+	return ( evt, changedItems ) => {
+		let valueToSet;
+
+		const root = changedItems[ 0 ];
+		const rootIndent = root.getAttribute( 'listIndent' );
+
+		const itemsToUpdate = changedItems.filter( item => item.getAttribute( 'listIndent' ) === rootIndent );
+
+		// A case where a few list items are intended must be checked separately
+		// since `getSiblingListItem()` returns the first changed element.
+		// ■ List item 1.
+		//     ○ [List item 2.
+		//     ○ List item 3.]
+		// ■ List item 4.
+		//
+		// List items: `2` and `3` should be adjusted.
+		if ( root.previousSibling.getAttribute( 'listIndent' ) + 1 === rootIndent ) {
+			// valueToSet = root.previousSibling.getAttribute( 'listStyle' ) || DEFAULT_LIST_TYPE;
+			valueToSet = DEFAULT_LIST_TYPE;
+		} else {
+			const previousSibling = getSiblingListItem( root.previousSibling, {
+				sameIndent: true, direction: 'backward', listIndent: rootIndent
+			} );
+
+			valueToSet = previousSibling.getAttribute( 'listStyle' );
+		}
+
+		editor.model.change( writer => {
+			for ( const item of itemsToUpdate ) {
+				writer.setAttribute( 'listStyle', valueToSet, item );
+			}
+		} );
+	};
+}
+
+// When outdenting a list, a nested list should copy its value for the `listStyle` attribute
+// from the previous sibling list item including the same value for the `listIndent` value.
+//
+// ■ List item 1.
+//     ○ List item 2.[]
+// ■ List item 3.
+//
+// editor.execute( 'outdentList' );
+//
+// ■ List item 1.
+// ■ List item 2.[]
+// ■ List item 3.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListAfterOutdentListCommand( editor ) {
+	return ( evt, changedItems ) => {
+		changedItems = changedItems.reverse().filter( item => item.is( 'element', 'listItem' ) );
+
+		if ( !changedItems.length ) {
+			return;
+		}
+
+		const indent = changedItems[ 0 ].getAttribute( 'listIndent' );
+		const listType = changedItems[ 0 ].getAttribute( 'listType' );
+		let listItem = changedItems[ 0 ].previousSibling;
+
+		// ■ List item 1.
+		//     ○ List item 2.
+		//     ○ List item 3.[]
+		// ■ List item 4.
+		//
+		// After outdenting a list, `List item 3` should inherit the `listStyle` attribute from `List item 1`.
+		//
+		// ■ List item 1.
+		//     ○ List item 2.
+		// ■ List item 3.[]
+		// ■ List item 4.
+		if ( listItem.is( 'element', 'listItem' ) ) {
+			while ( listItem.getAttribute( 'listIndent' ) !== indent ) {
+				listItem = listItem.previousSibling;
+			}
+		} else {
+			listItem = null;
+		}
+
+		// Outdenting such a list should restore values based on `List item 4`.
+		// ■ List item 1.[]
+		//     ○ List item 2.
+		//     ○ List item 3.
+		// ■ List item 4.
+		if ( !listItem ) {
+			listItem = changedItems[ changedItems.length - 1 ].nextSibling;
+		}
+
+		// And such a list should not modify anything.
+		// However, `listItem` can indicate a node below the list. Be sure that we have the `listItem` element.
+		// ■ List item 1.[]
+		//     ○ List item 2.
+		//     ○ List item 3.
+		// <paragraph>The later if check.</paragraph>
+		if ( !listItem || !listItem.is( 'element', 'listItem' ) ) {
+			return;
+		}
+
+		// Do not modify the list if found `listItem` represents other type of list than outdented list items.
+		if ( listItem.getAttribute( 'listType' ) !== listType ) {
+			return;
+		}
+
+		editor.model.change( writer => {
+			const itemsToUpdate = changedItems.filter( item => item.getAttribute( 'listIndent' ) === indent );
+
+			for ( const item of itemsToUpdate ) {
+				writer.setAttribute( 'listStyle', listItem.getAttribute( 'listStyle' ), item );
+			}
+		} );
+	};
+}
+
+// Each `listItem` element must have specified the `listStyle` attribute.
+// This post-fixer checks whether inserted elements `listItem` elements should inherit the `listStyle` value from
+// their sibling nodes or should use the default value.
+//
+// Paragraph[]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2. // ...
+// ■ List item 3. // ...
+//
+// editor.execute( 'bulletedList' )
+//
+// ■ Paragraph[]  // [listStyle="square", listType="bulleted"]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2.
+// ■ List item 3.
+//
+// It also covers a such change:
+//
+// [Paragraph 1
+// Paragraph 2]
+// ■ List item 1. // [listStyle="square", listType="bulleted"]
+// ■ List item 2. // ...
+// ■ List item 3. // ...
+//
+// editor.execute( 'numberedList' )
+//
+// 1. [Paragraph 1 // [listStyle="default", listType="numbered"]
+// 2. Paragraph 2] // [listStyle="default", listType="numbered"]
+// ■ List item 1.  // [listStyle="square", listType="bulleted"]
+// ■ List item 2.  // ...
+// ■ List item 3.  // ...
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function fixListStyleAttributeOnListItemElements( editor ) {
+	return writer => {
+		let wasFixed = false;
+		let insertedListItems = [];
+
+		for ( const change of editor.model.document.differ.getChanges() ) {
+			if ( change.type == 'insert' && change.name == 'listItem' ) {
+				insertedListItems.push( change.position.nodeAfter );
+			}
+		}
+
+		// Don't touch todo lists.
+		insertedListItems = insertedListItems.filter( item => item.getAttribute( 'listType' ) !== 'todo' );
+
+		if ( !insertedListItems.length ) {
+			return wasFixed;
+		}
+
+		// Check whether the last inserted element is next to the `listItem` element.
+		//
+		// ■ Paragraph[]  // <-- The inserted item.
+		// ■ List item 1.
+		let existingListItem = insertedListItems[ insertedListItems.length - 1 ].nextSibling;
+
+		// If it doesn't, maybe the `listItem` was inserted at the end of the list.
+		//
+		// ■ List item 1.
+		// ■ Paragraph[]  // <-- The inserted item.
+		if ( !existingListItem || !existingListItem.is( 'element', 'listItem' ) ) {
+			existingListItem = insertedListItems[ insertedListItems.length - 1 ].previousSibling;
+
+			if ( existingListItem ) {
+				const indent = insertedListItems[ 0 ].getAttribute( 'listIndent' );
+
+				// But we need to find a `listItem` with the `listIndent=0` attribute.
+				// If doesn't, maybe the `listItem` was inserted at the end of the list.
+				//
+				// ■ List item 1.
+				//     ○ List item 2.
+				// ■ Paragraph[]  // <-- The inserted item.
+				while ( existingListItem.is( 'element', 'listItem' ) && existingListItem.getAttribute( 'listIndent' ) !== indent ) {
+					existingListItem = existingListItem.previousSibling;
+				}
+			}
+		}
+
+		for ( const item of insertedListItems ) {
+			if ( !item.hasAttribute( 'listStyle' ) ) {
+				if ( shouldInheritListType( existingListItem ) ) {
+					writer.setAttribute( 'listStyle', existingListItem.getAttribute( 'listStyle' ), item );
+				} else {
+					writer.setAttribute( 'listStyle', DEFAULT_LIST_TYPE, item );
+				}
+
+				wasFixed = true;
+			}
+		}
+
+		return wasFixed;
+	};
+
+	// Checks whether the `listStyle` attribute should be copied from the `baseItem` element.
+	//
+	// The attribute should be copied if the inserted element does not have defined it and
+	// the value for the element is other than default in the base element.
+	//
+	// @param {module:engine/model/element~Element|null} baseItem
+	// @returns {Boolean}
+	function shouldInheritListType( baseItem ) {
+		if ( !baseItem ) {
+			return false;
+		}
+
+		const baseListStyle = baseItem.getAttribute( 'listStyle' );
+
+		if ( !baseListStyle ) {
+			return false;
+		}
+
+		if ( baseListStyle === DEFAULT_LIST_TYPE ) {
+			return false;
+		}
+
+		return true;
+	}
+}
+
+// Removes the `listStyle` attribute from "todo" list items.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function removeListStyleAttributeFromTodoList( editor ) {
+	return writer => {
+		let todoListItems = [];
+
+		for ( const change of editor.model.document.differ.getChanges() ) {
+			const item = getItemFromChange( change );
+
+			if ( item && item.is( 'element', 'listItem' ) && item.getAttribute( 'listType' ) === 'todo' ) {
+				todoListItems.push( item );
+			}
+		}
+
+		todoListItems = todoListItems.filter( item => item.hasAttribute( 'listStyle' ) );
+
+		if ( !todoListItems.length ) {
+			return false;
+		}
+
+		for ( const item of todoListItems ) {
+			writer.removeAttribute( 'listStyle', item );
+		}
+
+		return true;
+	};
+
+	function getItemFromChange( change ) {
+		if ( change.type === 'attribute' ) {
+			return change.range.start.nodeAfter;
+		}
+
+		if ( change.type === 'insert' ) {
+			return change.position.nodeAfter;
+		}
+
+		return null;
+	}
+}
+
+// Restores the `listStyle` attribute after changing the list type.
+//
+// @private
+// @param {module:core/editor/editor~Editor} editor
+// @returns {Function}
+function restoreDefaultListStyle( editor ) {
+	return ( evt, changedItems ) => {
+		changedItems = changedItems.filter( item => item.is( 'element', 'listItem' ) );
+
+		editor.model.change( writer => {
+			for ( const item of changedItems ) {
+				// Remove the attribute. Post-fixer will restore the proper value.
+				writer.removeAttribute( 'listStyle', item );
+			}
+		} );
+	};
+}

+ 230 - 0
packages/ckeditor5-list/src/liststyleui.js

@@ -0,0 +1,230 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module list/liststyleui
+ */
+
+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 ListStyleUI extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'ListStyleUI';
+	}
+
+	init() {
+		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 listStyleCommand = editor.commands.get( 'listStyle' );
+
+	// @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, listStyleCommand } );
+
+		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/liststylecommand~ListStylesCommand} options.listStyleCommand 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, listStyleCommand, 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 } );
+
+		listStyleCommand.on( 'change:value', () => {
+			button.isOn = listStyleCommand.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 ( listStyleCommand.value !== type ) {
+					editor.execute( 'listStyle', { type } );
+				}
+				// If the style was the same, remove it (the button works as an off toggle).
+				else {
+					editor.execute( 'listStyle', { type: listStyleCommand._defaultType } );
+				}
+			}
+			// If the content the selection is anchored to is not a list, let's create a list of a desired style.
+			else {
+				editor.model.change( () => {
+					editor.execute( parentCommandName );
+					editor.execute( 'listStyle', { type } );
+				} );
+			}
+
+			editor.editing.view.focus();
+		} );
+
+		return button;
+	};
+}

+ 21 - 1
packages/ckeditor5-list/tests/indentcommand.js

@@ -37,7 +37,7 @@ describe( 'IndentCommand', () => {
 		);
 	} );
 
-	describe( 'IndentCommand', () => {
+	describe( 'IndentCommand - forward (indent)', () => {
 		let command;
 
 		beforeEach( () => {
@@ -193,6 +193,26 @@ describe( 'IndentCommand', () => {
 					'<listItem listIndent="0" listType="bulleted">g</listItem>'
 				);
 			} );
+
+			it( 'should fire "_executeCleanup" event after finish all operations with all changed items', done => {
+				model.change( writer => {
+					writer.setSelection( root.getChild( 1 ), 0 );
+				} );
+
+				command.on( '_executeCleanup', ( evt, data ) => {
+					expect( data ).to.deep.equal( [
+						root.getChild( 1 ),
+						root.getChild( 2 ),
+						root.getChild( 3 ),
+						root.getChild( 4 ),
+						root.getChild( 5 )
+					] );
+
+					done();
+				} );
+
+				command.execute();
+			} );
 		} );
 	} );
 

+ 29 - 0
packages/ckeditor5-list/tests/listcommand.js

@@ -454,6 +454,35 @@ describe( 'ListCommand', () => {
 					expect( getData( model ) ).to.equal( expectedData );
 				} );
 			} );
+
+			it( 'should fire "_executeCleanup" event after finish all operations with all changed items', done => {
+				setData( model,
+					'<paragraph>Foo 1.</paragraph>' +
+					'<paragraph>[Foo 2.</paragraph>' +
+					'<paragraph>Foo 3.]</paragraph>' +
+					'<paragraph>Foo 4.</paragraph>'
+				);
+
+				command.execute();
+
+				expect( getData( model ) ).to.equal(
+					'<paragraph>Foo 1.</paragraph>' +
+					'<listItem listIndent="0" listType="bulleted">[Foo 2.</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">Foo 3.]</listItem>' +
+					'<paragraph>Foo 4.</paragraph>'
+				);
+
+				command.on( '_executeCleanup', ( evt, data ) => {
+					expect( data ).to.deep.equal( [
+						root.getChild( 2 ),
+						root.getChild( 1 )
+					] );
+
+					done();
+				} );
+
+				command.execute();
+			} );
 		} );
 	} );
 } );

+ 18 - 0
packages/ckeditor5-list/tests/liststyle.js

@@ -0,0 +1,18 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ListStyle from '../src/liststyle';
+import ListStyleEditing from '../src/liststyleediting';
+import ListStyleUI from '../src/liststyleui';
+
+describe( 'ListStyle', () => {
+	it( 'should be named', () => {
+		expect( ListStyle.pluginName ).to.equal( 'ListStyle' );
+	} );
+
+	it( 'should require ListStyleEditing and ListStyleUI', () => {
+		expect( ListStyle.requires ).to.deep.equal( [ ListStyleEditing, ListStyleUI ] );
+	} );
+} );

+ 391 - 0
packages/ckeditor5-list/tests/liststylecommand.js

@@ -0,0 +1,391 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import { setData, getData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import ListStyleEditing from '../src/liststyleediting';
+
+describe( 'ListStyleCommand', () => {
+	let editor, model, bulletedListCommand, numberedListCommand, listStyleCommand;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, ListStyleEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				bulletedListCommand = editor.commands.get( 'bulletedList' );
+				numberedListCommand = editor.commands.get( 'numberedList' );
+				listStyleCommand = editor.commands.get( 'listStyle' );
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	describe( '#isEnabled', () => {
+		it( 'should be true if bulletedList or numberedList is enabled', () => {
+			bulletedListCommand.isEnabled = true;
+			numberedListCommand.isEnabled = false;
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( true );
+
+			bulletedListCommand.isEnabled = false;
+			numberedListCommand.isEnabled = true;
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( true );
+		} );
+
+		it( 'should be false if bulletedList and numberedList are enabled', () => {
+			bulletedListCommand.isEnabled = false;
+			numberedListCommand.isEnabled = false;
+
+			listStyleCommand.refresh();
+
+			expect( listStyleCommand.isEnabled ).to.equal( false );
+		} );
+	} );
+
+	describe( '#value', () => {
+		it( 'should return null if selected a paragraph', () => {
+			setData( model, '<paragraph>Foo[]</paragraph>' );
+
+			expect( listStyleCommand.value ).to.equal( null );
+		} );
+
+		it( 'should return null if selection starts in a paragraph and ends in a list item', () => {
+			setData( model,
+				'<paragraph>Fo[o</paragraph>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo]</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( null );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selection is inside a listItem (collapsed selection)', () => {
+			setData( model, '<listItem listIndent="0" listType="bulleted" listStyle="default">Foo[]</listItem>' );
+
+			expect( listStyleCommand.value ).to.equal( 'default' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selection is inside a listItem (non-collapsed selection)', () => {
+			setData( model, '<listItem listIndent="0" listType="bulleted" listStyle="default">[Foo]</listItem>' );
+
+			expect( listStyleCommand.value ).to.equal( 'default' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute if selected more elements in the same list', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">[1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.]</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">3.</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'square' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute for the selection inside a nested list', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="disc">1.1.[]</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'disc' );
+		} );
+
+		it( 'should return the value of `listStyle` attribute from a list where the selection starts (selection over nested list)', () => {
+			setData( model,
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">1.</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="disc">1.1.[</listItem>' +
+				'<listItem listIndent="0" listType="bulleted" listStyle="square">2.]</listItem>'
+			);
+
+			expect( listStyleCommand.value ).to.equal( 'disc' );
+		} );
+	} );
+
+	describe( 'execute()', () => {
+		it( 'should set the `listStyle` attribute for collapsed selection', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for non-collapsed selection', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[1.]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">[1.]</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items (collapsed selection)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items and ignores nested lists', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+		} );
+
+		it( 'should set the `listStyle` attribute for all the same list items and ignores "parent" list (selection in nested list)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.[]</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'disc' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.1.[]</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted non-listItem element', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted listItem with different listType attribute', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="numbered">1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="numbered">1.</listItem>'
+			);
+		} );
+
+		it( 'should stop searching for the list items when spotted listItem with different listStyle attribute', () => {
+			setData( model,
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>'
+			);
+		} );
+
+		it( 'should start searching for the list items from starting position (collapsed selection)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[3.</listItem>' +
+				'<paragraph>Foo.]</paragraph>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">[3.</listItem>' +
+				'<paragraph>Foo.]</paragraph>'
+			);
+		} );
+
+		it( 'should start searching for the list items from ending position (collapsed selection)', () => {
+			setData( model,
+				'<paragraph>[Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.]</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>[Foo.</paragraph>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.]</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (no options passed)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute();
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (passed an empty object)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( {} );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should use default type if not specified (passed null as value)', () => {
+			setData( model,
+				'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+			);
+
+			listStyleCommand.execute( { type: null } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.[]</listItem>'
+			);
+		} );
+
+		it( 'should not update anything if no listItem found in the selection', () => {
+			setData( model,
+				'<paragraph>[Foo.]</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>'
+			);
+
+			const modelChangeStub = sinon.stub( model, 'change' ).named( 'model#change' );
+
+			listStyleCommand.execute( { type: 'circle' } );
+
+			expect( getData( model ) ).to.equal(
+				'<paragraph>[Foo.]</paragraph>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>'
+			);
+
+			expect( modelChangeStub.called ).to.equal( false );
+		} );
+
+		it( 'should update all items that belong to selected elements', () => {
+			// [x] = items that should be updated.
+			// All list items that belong to the same lists that selected items should be updated.
+			// "2." is the most outer list (listIndent=0)
+			// "2.1" a child list of the "2." element (listIndent=1)
+			// "2.1.1" a child list of the "2.1" element (listIndent=2)
+			//
+			// [x] ■ 1.
+			// [x] ■ [2.
+			// [x]     ○ 2.1.
+			// [x]         ▶ 2.1.1.]
+			// [x]         ▶ 2.1.2.
+			// [x]     ○ 2.2.
+			// [x] ■ 3.
+			// [ ]     ○ 3.1.
+			// [ ]         ▶ 3.1.1.
+			//
+			// "3.1" is not selected and this list should not be updated.
+			setData( model,
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">[2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">2.1.1.]</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">2.1.2.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="default" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">3.1.1.</listItem>'
+			);
+
+			listStyleCommand.execute( { type: 'disc' } );
+
+			expect( getData( model ) ).to.equal(
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">1.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">[2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="disc" listType="bulleted">2.1.1.]</listItem>' +
+				'<listItem listIndent="2" listStyle="disc" listType="bulleted">2.1.2.</listItem>' +
+				'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.2.</listItem>' +
+				'<listItem listIndent="0" listStyle="disc" listType="bulleted">3.</listItem>' +
+				'<listItem listIndent="1" listStyle="default" listType="bulleted">3.1.</listItem>' +
+				'<listItem listIndent="2" listStyle="default" listType="bulleted">3.1.1.</listItem>'
+			);
+		} );
+	} );
+} );

+ 896 - 0
packages/ckeditor5-list/tests/liststyleediting.js

@@ -0,0 +1,896 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import ListStyleEditing from '../src/liststyleediting';
+import TodoListEditing from '../src/todolistediting';
+import ListStyleCommand from '../src/liststylecommand';
+
+describe( 'ListStyleEditing', () => {
+	let editor, model, view;
+
+	beforeEach( () => {
+		return VirtualTestEditor
+			.create( {
+				plugins: [ Paragraph, ListStyleEditing, UndoEditing ]
+			} )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+				view = editor.editing.view;
+			} );
+	} );
+
+	afterEach( () => {
+		return editor.destroy();
+	} );
+
+	it( 'should have pluginName', () => {
+		expect( ListStyleEditing.pluginName ).to.equal( 'ListStyleEditing' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ListStyleEditing ) ).to.be.instanceOf( ListStyleEditing );
+	} );
+
+	describe( 'schema rules', () => {
+		it( 'should allow set `listStyle` on the `listItem`', () => {
+			expect( model.schema.checkAttribute( [ '$root', 'listItem' ], 'listStyle' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'command', () => {
+		it( 'should register listStyle command', () => {
+			const command = editor.commands.get( 'listStyle' );
+
+			expect( command ).to.be.instanceOf( ListStyleCommand );
+		} );
+	} );
+
+	describe( 'conversion in data pipeline', () => {
+		describe( 'model to data', () => {
+			it( 'should convert single list (type: bulleted)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert single list (type: bulleted, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="default">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ul style="list-style-type:circle;"><li>Foo</li><li>Bar</li></ul>' );
+			} );
+
+			it( 'should convert single list (type: numbered, style: upper-alpha)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="upper-alpha">Foo</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="upper-alpha">Bar</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal( '<ol style="list-style-type:upper-alpha;"><li>Foo</li><li>Bar</li></ol>' );
+			} );
+
+			it( 'should convert nested bulleted lists (main: circle, nested: disc)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:circle;">' +
+						'<li>Foo 1' +
+							'<ul style="list-style-type:disc;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should convert nested numbered lists (main: decimal-leading-zero, nested: lower-latin)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-latin">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-latin">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal-leading-zero">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal-leading-zero;">' +
+						'<li>Foo 1' +
+							'<ol style="list-style-type:lower-latin;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ol>'
+				);
+			} );
+
+			it( 'should convert nested mixed lists (ul>ol, main: square, nested: lower-roman)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-roman">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="lower-roman">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="square">Foo 3</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:square;">' +
+						'<li>Foo 1' +
+							'<ol style="list-style-type:lower-roman;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should produce nested lists (different `listIndent` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 2</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="decimal">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="numbered" listStyle="decimal">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2' +
+							'<ol style="list-style-type:decimal;">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ol>' +
+						'</li>' +
+					'</ol>'
+				);
+			} );
+
+			it( 'should produce two different lists (different `listType` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="numbered" listStyle="decimal">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ol style="list-style-type:decimal;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2</li>' +
+					'</ol>' +
+					'<ul style="list-style-type:disc;">' +
+						'<li>Bar 1</li>' +
+						'<li>Bar 2</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should produce two different lists (different `listStyle` attribute)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Foo 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="disc">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar 1</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar 2</listItem>'
+				);
+
+				expect( editor.getData() ).to.equal(
+					'<ul style="list-style-type:disc;">' +
+						'<li>Foo 1</li>' +
+						'<li>Foo 2</li>' +
+					'</ul>' +
+					'<ul style="list-style-type:circle;">' +
+						'<li>Bar 1</li>' +
+						'<li>Bar 2</li>' +
+					'</ul>'
+				);
+			} );
+
+			it( 'should not allow to set the `listStyle` attribute in to-do list item', () => {
+				setModelData( model, '<listItem listIndent="0" listType="todo">Foo</listItem>' );
+
+				const listItem = model.document.getRoot().getChild( 0 );
+
+				expect( listItem.hasAttribute( 'listItem' ) ).to.be.false;
+
+				model.change( writer => {
+					writer.setAttribute( 'listType', 'foo', listItem );
+				} );
+
+				expect( listItem.hasAttribute( 'listItem' ) ).to.be.false;
+			} );
+		} );
+
+		describe( 'view to model', () => {
+			it( 'should convert single list (type: bulleted)', () => {
+				editor.setData( '<ul><li>Foo</li><li>Bar</li></ul>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: numbered)', () => {
+				editor.setData( '<ol><li>Foo</li><li>Bar</li></ol>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				editor.setData( '<ul style="list-style-type:circle;"><li>Foo</li><li>Bar</li></ul>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert single list (type: numbered, style: upper-alpha)', () => {
+				editor.setData( '<ol style="list-style-type:upper-alpha;"><li>Foo</li><li>Bar</li></ol>' );
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should convert nested and mixed lists', () => {
+				editor.setData(
+					'<ol style="list-style-type:upper-alpha;">' +
+						'<li>OL 1</li>' +
+						'<li>OL 2' +
+							'<ul style="list-style-type:circle;">' +
+								'<li>UL 1</li>' +
+								'<li>UL 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>OL 3</li>' +
+					'</ol>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 1</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 2</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">UL 1</listItem>' +
+					'<listItem listIndent="1" listStyle="circle" listType="bulleted">UL 2</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">OL 3</listItem>'
+				);
+			} );
+
+			it( 'should convert when the list is in the middle of the content', () => {
+				editor.setData(
+					'<p>Paragraph.</p>' +
+					'<ol style="list-style-type:upper-alpha;">' +
+						'<li>Foo</li>' +
+						'<li>Bar</li>' +
+					'</ol>' +
+					'<p>Paragraph.</p>'
+				);
+
+				expect( getModelData( model, { withoutSelection: true } ) ).to.equal(
+					'<paragraph>Paragraph.</paragraph>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="upper-alpha" listType="numbered">Bar</listItem>' +
+					'<paragraph>Paragraph.</paragraph>'
+				);
+			} );
+		} );
+	} );
+
+	// At this moment editing and data pipelines produce exactly the same content.
+	// Just a few tests will be enough here. `model to data` block contains all cases checked.
+	describe( 'conversion in editing pipeline', () => {
+		describe( 'model to view', () => {
+			it( 'should convert single list (type: bulleted, style: default)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="default">Bar</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul><li>Foo</li><li>Bar</li></ul>'
+				);
+			} );
+
+			it( 'should convert single list (type: bulleted, style: circle)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Bar</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul style="list-style-type:circle"><li>Foo</li><li>Bar</li></ul>'
+				);
+			} );
+
+			it( 'should convert nested bulleted lists (main: circle, nested: disc)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 1</listItem>' +
+					'<listItem listIndent="1" listType="bulleted" listStyle="disc">Bar 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 2</listItem>' +
+					'<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo 3</listItem>'
+				);
+
+				expect( getViewData( view, { withoutSelection: true } ) ).to.equal(
+					'<ul style="list-style-type:circle">' +
+						'<li>Foo 1' +
+							'<ul style="list-style-type:disc">' +
+								'<li>Bar 1</li>' +
+								'<li>Bar 2</li>' +
+							'</ul>' +
+						'</li>' +
+						'<li>Foo 2</li>' +
+						'<li>Foo 3</li>' +
+					'</ul>'
+				);
+			} );
+		} );
+	} );
+
+	describe( 'integrations', () => {
+		describe( 'merging a list into a styled list', () => {
+			it( 'should inherit the list style attribute when merging the same kind of lists (from top, merge a single item)', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute when merging the same kind of lists (from top, merge a few items)', () => {
+				setModelData( model,
+					'<paragraph>[Foo Bar 1.</paragraph>' +
+					'<paragraph>Foo Bar 2.]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[Foo Bar 1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar 2.]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+
+			it( 'should not inherit anything if there is no list below the inserted list', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar 1.[]</paragraph>' +
+					'<paragraph>Foo Bar 2.</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar 1.[]</listItem>' +
+					'<paragraph>Foo Bar 2.</paragraph>'
+				);
+			} );
+
+			it( 'should not inherit anything if replacing the entire content with a list', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar 1.[]</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar 1.[]</listItem>'
+				);
+			} );
+
+			it( 'should not inherit the list style attribute when merging different kind of lists (from top, merge a single item)', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="default"  listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default"  listType="numbered">Bar</listItem>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="default" listType="bulleted">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="default" listType="numbered">Bar</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute when merging the same kind of lists (from bottom, merge a single item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>' +
+					'<paragraph>Foo Bar.[]</paragraph>'
+				);
+
+				editor.execute( 'bulletedList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>'
+				);
+			} );
+
+			it(
+				'should inherit the list style attribute from listIndent=0 element when merging the same kind of lists (from bottom)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">Bar</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">Foo Bar</listItem>' +
+						'<paragraph>Foo Bar.[]</paragraph>'
+					);
+
+					editor.execute( 'bulletedList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">Bar</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">Foo Bar</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo Bar.[]</listItem>'
+					);
+				}
+			);
+		} );
+
+		describe( 'indenting lists', () => {
+			it( 'should restore the default value for the list style attribute when indenting a single item', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should restore the default value for the list style attribute when indenting a few items', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">3.]</listItem>'
+				);
+			} );
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list (default value)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list (changed value)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">3.[]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it( 'should copy the value for the list style attribute when indenting a single item into a nested list', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+				);
+			} );
+
+			it(
+				'should copy the value for the list style attribute when indenting a single item into a nested list ' +
+				'(many nested lists check)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.[]</listItem>'
+					);
+
+					editor.execute( 'indentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="1" listStyle="disc" listType="bulleted">4.[]</listItem>'
+					);
+				}
+			);
+
+			it( 'should inherit the list style attribute from nested list if the `listType` is other than indenting element', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">3.[]</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'outdenting lists', () => {
+			it( 'should inherit the list style attribute from parent list (change the first nested item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute from parent list (change the second nested item)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">3.[]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.[]</listItem>'
+				);
+			} );
+
+			it( 'should inherit the list style attribute from parent list (modifying nested lists)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">[2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.]</listItem>'
+				);
+			} );
+
+			it(
+				'should inherit the list style attribute from parent list (outdenting many items, including the first one in the list)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">[1.</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+
+					editor.execute( 'outdentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>[1.</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">3.]</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">4.</listItem>'
+					);
+				}
+			);
+
+			it(
+				'should inherit the list style attribute from parent list (outdenting the first item that is a parent for next list)',
+				() => {
+					setModelData( model,
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+						'<listItem listIndent="1" listStyle="default" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="3" listStyle="disc" listType="bulleted">4.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">5.</listItem>'
+					);
+
+					editor.execute( 'outdentList' );
+
+					expect( getModelData( model ) ).to.equal(
+						'<paragraph>1.[]</paragraph>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.</listItem>' +
+						'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+						'<listItem listIndent="2" listStyle="disc" listType="bulleted">4.</listItem>' +
+						'<listItem listIndent="0" listStyle="circle" listType="bulleted">5.</listItem>'
+					);
+				}
+			);
+
+			it( 'should not inherit the list style if outdented the only one item in the list', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should not inherit the list style if outdented the only one item in the list (a paragraph below the list)', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>' +
+					'<listItem listIndent="1" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="2" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph>Foo</paragraph>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="disc" listType="bulleted">2.</listItem>' +
+					'<listItem listIndent="1" listStyle="square" listType="bulleted">3.</listItem>' +
+					'<paragraph>Foo</paragraph>'
+				);
+			} );
+
+			it( 'should not inherit the list style attribute from parent list if the `listType` is other than outdenting element', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="decimal" listType="numbered">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="0" listStyle="decimal" listType="numbered">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should not do anything if there is no list after outdenting', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.[]</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<paragraph>1.[]</paragraph>'
+				);
+			} );
+		} );
+
+		describe( 'indent/outdent + undo', () => {
+			it( 'should use the same batch for indenting a list and updating `listType` attribute', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'indentList' );
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">1A.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2B.</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+
+			it( 'should use the same batch for outdenting a list and updating `listType` attribute', () => {
+				setModelData( model,
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+
+				editor.execute( 'outdentList' );
+				editor.execute( 'undo' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">1.</listItem>' +
+					'<listItem listIndent="1" listStyle="default" listType="bulleted">2.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">3.</listItem>'
+				);
+			} );
+		} );
+
+		describe( 'todo list', () => {
+			let editor, model;
+
+			beforeEach( () => {
+				return VirtualTestEditor
+					.create( {
+						// TodoListEditing is at the end by design. Check `ListStyleEditing.afterInit()` call.
+						plugins: [ Paragraph, ListStyleEditing, TodoListEditing ]
+					} )
+					.then( newEditor => {
+						editor = newEditor;
+						model = editor.model;
+					} );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			it( 'should not add the `listStyle` attribute while creating a todo list', () => {
+				setModelData( model, '<paragraph>Foo[]</paragraph>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should not add the `listStyle` attribute while switching the list type', () => {
+				setModelData( model, '<listItem listIndent="0" listType="bulleted">Foo[]</listItem>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should remove the `listStyle` attribute while switching the list type that uses the list style feature', () => {
+				setModelData( model, '<listItem listIndent="0" listType="bulleted" listStyle="circle">Foo[]</listItem>' );
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ), '<listItem listIndent="0" listType="todo">Foo[]</listItem>' );
+			} );
+
+			it( 'should  not inherit the list style attribute when inserting a todo list item', () => {
+				setModelData( model,
+					'<paragraph>Foo Bar.[]</paragraph>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+
+				editor.execute( 'todoList' );
+
+				expect( getModelData( model ) ).to.equal(
+					'<listItem listIndent="0" listType="todo">Foo Bar.[]</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Foo</listItem>' +
+					'<listItem listIndent="0" listStyle="circle" listType="bulleted">Bar</listItem>'
+				);
+			} );
+		} );
+	} );
+} );

+ 480 - 0
packages/ckeditor5-list/tests/liststyleui.js

@@ -0,0 +1,480 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * 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 { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import ListStyle from '../src/liststyle';
+import ListStyleUI from '../src/liststyleui';
+import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview';
+import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+
+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( 'ListStyleUI', () => {
+	let editorElement, editor, model, listStyleCommand;
+
+	beforeEach( () => {
+		editorElement = document.createElement( 'div' );
+		document.body.appendChild( editorElement );
+
+		return ClassicTestEditor.create( editorElement, { plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ] } )
+			.then( newEditor => {
+				editor = newEditor;
+				model = editor.model;
+
+				listStyleCommand = editor.commands.get( 'listStyle' );
+			} );
+	} );
+
+	afterEach( () => {
+		editorElement.remove();
+
+		return editor.destroy();
+	} );
+
+	it( 'should be named', () => {
+		expect( ListStyleUI.pluginName ).to.equal( 'ListStyleUI' );
+	} );
+
+	it( 'should be loaded', () => {
+		expect( editor.plugins.get( ListStyleUI ) ).to.be.instanceOf( ListStyleUI );
+	} );
+
+	describe( 'init()', () => {
+		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 "listStyleCommand" command changes', () => {
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'foo';
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'circle';
+						expect( styleButtonView.isOn ).to.be.true;
+
+						listStyleCommand.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, 'listStyle', { 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, 'listStyle', { 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, 'listStyle', { 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, 'bulletedList' );
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'circle' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should create the single undo step while selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						expect( getData( model ) ).to.equal(
+							'<listItem listIndent="0" listStyle="circle" listType="bulleted">foo[]</listItem>'
+						);
+
+						editor.execute( 'undo' );
+
+						expect( getData( model ) ).to.equal(
+							'<paragraph>foo[]</paragraph>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		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 "listStyleCommand" command changes', () => {
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'foo';
+						expect( styleButtonView.isOn ).to.be.false;
+
+						listStyleCommand.value = 'decimal-leading-zero';
+						expect( styleButtonView.isOn ).to.be.true;
+
+						listStyleCommand.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, 'listStyle', { 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, 'listStyle', { 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, 'listStyle', { 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' );
+
+						sinon.assert.calledWithExactly( editor.execute, 'numberedList' );
+						sinon.assert.calledWithExactly( editor.execute, 'listStyle', { type: 'decimal-leading-zero' } );
+						sinon.assert.calledOnce( editor.editing.view.focus );
+						sinon.assert.callOrder( editor.execute, editor.editing.view.focus );
+					} );
+
+					it( 'should create the single undo step while selection was not anchored in a list', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+
+						styleButtonView.fire( 'execute' );
+
+						expect( getData( model ) ).to.equal(
+							'<listItem listIndent="0" listStyle="decimal-leading-zero" listType="numbered">foo[]</listItem>'
+						);
+
+						editor.execute( 'undo' );
+
+						expect( getData( model ) ).to.equal(
+							'<paragraph>foo[]</paragraph>'
+						);
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 182 - 0
packages/ckeditor5-list/tests/manual/list-style.html

@@ -0,0 +1,182 @@
+<h3>List Styles UI</h3>
+<div id="editor">
+    <h2>Default styles</h2>
+    <figure class="table">
+        <table style="width: 600px;">
+            <tbody>
+            <tr>
+                <td colspan="2" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Default styles</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Bulleted</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Numbered</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 50%">
+                    <ul>
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 50%">
+                    <ol>
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Bulleted lists</h2>
+    <figure class="table">
+        <table style="width: 600px;">
+            <tbody>
+            <tr>
+                <td colspan="4" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Type of list</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Circle</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Disc</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Square</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">'👉'</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: circle;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: disc;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: square;">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ul style="list-style-type: '👉';">
+                        <li>UL List item 1</li>
+                        <li>UL List item 2</li>
+                        <li>UL List item 3</li>
+                    </ul>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Numbered lists</h2>
+    <figure class="table">
+        <table style="width: 800px;">
+            <tbody>
+            <tr>
+                <td colspan="4" style="text-align: center">
+                    <span class="text-big" style="font-weight: bold">Type of list</span>
+                </td>
+            </tr>
+            <tr>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Decimal</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Decimal-leading-zero</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Lower-latin</span></td>
+                <td style="text-align: center"><span class="text-big" style="font-weight: bold">Lower-roman</span></td>
+            </tr>
+            <tr>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: decimal;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: decimal-leading-zero;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: lower-latin;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+                <td style="vertical-align:top; width: 25%">
+                    <ol style="list-style-type: lower-roman;">
+                        <li>OL List item 1</li>
+                        <li>OL List item 2</li>
+                        <li>OL List item 3</li>
+                    </ol>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+    </figure>
+
+    <h2>Nested/mixed lists (numbered and bulleted)</h2>
+    <figure class="table">
+        <table style="width: 1200px;">
+            <tr>
+                <td style="width: 50%">
+                    <ol style="list-style-type: lower-roman;">
+                        <li>OL 1.1 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.2 <code>OL[style: lower-roman]</code>
+                            <ul style="list-style-type: square;">
+                                <li>UL 1.2.1 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                                <li>UL 1.2.2 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                                <li>UL 1.2.3 <code>OL[style: lower-roman] &gt; UL[style: square]</code></li>
+                            </ul>
+                        </li>
+                        <li>OL 1.3 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.4 <code>OL[style: lower-roman]</code></li>
+                        <ol style="list-style-type: lower-alpha;">
+                            <li>OL 1.4.1 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                            <li>OL 1.4.2 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                            <li>OL 1.4.3 <code>OL[style: lower-roman] &gt; OL[style: lower-alpha]</code></li>
+                        </ol>
+                        </li>
+                        <li>OL 1.5 <code>OL[style: lower-roman]</code></li>
+                        <li>OL 1.6 <code>OL[style: lower-roman]</code></li>
+                    </ol>
+                </td>
+                <td style="width: 50%">
+                    <ol style="list-style-type: upper-alpha;">
+                        <li>OL 2.1 <code>OL[style: upper-alpha]</code>
+                            <ol style="list-style-type: lower-greek;">
+                                <li>OL 2.2.1 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code>
+                                    <ul style="list-style-type: circle;">
+                                        <li>UL 2.2.1.1 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                        <li>UL 2.2.1.2 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                        <li>UL 2.2.1.3 <code>... &gt; OL[style: lower-greek] &gt; UL[style: circle]</code></li>
+                                    </ul>
+                                </li>
+                                <li>OL 2.2.2 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code></li>
+                                <li>OL 2.2.3 <code>OL[style: lower-alpha] &gt; OL[style: lower-greek]</code></li>
+                            </ol>
+                        </li>
+                        <li>OL 2.2 <code>OL[style: upper-alpha]</code></li>
+                    </ol>
+                </td>
+            </tr>
+        </table>
+    </figure>
+</div>
+

+ 65 - 0
packages/ckeditor5-list/tests/manual/list-style.js

@@ -0,0 +1,65 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Code from '@ckeditor/ckeditor5-basic-styles/src/code';
+import Heading from '@ckeditor/ckeditor5-heading/src/heading';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Table from '@ckeditor/ckeditor5-table/src/table';
+import TablePropertiesEditing from '@ckeditor/ckeditor5-table/src/tableproperties/tablepropertiesediting';
+import TableCellPropertiesEditing from '@ckeditor/ckeditor5-table/src/tablecellproperties/tablecellpropertiesediting';
+import List from '../../src/list';
+import ListStyle from '../../src/liststyle';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
+import TodoList from '../../src/todolist';
+import RemoveFormat from '@ckeditor/ckeditor5-remove-format/src/removeformat';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [
+			Essentials,
+			Bold,
+			Italic,
+			Code,
+			Heading,
+			List,
+			TodoList,
+			Paragraph,
+			ListStyle,
+			Table,
+			TablePropertiesEditing,
+			TableCellPropertiesEditing,
+			Indent,
+			IndentBlock,
+			RemoveFormat
+		],
+		toolbar: [
+			'heading',
+			'|',
+			'bold',
+			'italic',
+			'|',
+			'removeFormat',
+			'|',
+			'bulletedList', 'numberedList', 'todoList',
+			'|',
+			'outdent',
+			'indent',
+			'|',
+			'undo', 'redo'
+		]
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 17 - 0
packages/ckeditor5-list/tests/manual/list-style.md

@@ -0,0 +1,17 @@
+# List Style feature
+
+The editor should display 4 headers and tables.
+
+1. **Default styles**<br>
+    A table should contain two columns that display default styles for `<ul>` and `<ol>` lists.
+2. **Bulleted lists**
+    A table should contain four columns with different bulleted lists
+    (`circle`, `disc`, `square`, `'👉'`).
+3. **Numbered lists**
+    A table should contain four columns with different numbered lists
+    (`decimal`, `decimal-leading-zero`, `lower-latin`, `lower-roman`).
+4. **Nested/mixed lists**
+    A table with two columns that contains nested and mixed `<ol>` and `<ul>` elements with
+    different values for `list-style-type`.
+    
+Check whether the `list-style-type` value is the same as the table or the list item describes.

+ 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>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 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>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 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' );
 		} );
 	} );
 

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно