diff --git a/packages/ckeditor5-find-and-replace/src/ui/findandreplaceformview.js b/packages/ckeditor5-find-and-replace/src/ui/findandreplaceformview.js index c59e1d889de..01d3a6b3671 100644 --- a/packages/ckeditor5-find-and-replace/src/ui/findandreplaceformview.js +++ b/packages/ckeditor5-find-and-replace/src/ui/findandreplaceformview.js @@ -28,7 +28,8 @@ import { FocusTracker, KeystrokeHandler, Collection, - Rect + Rect, + isVisible } from 'ckeditor5/src/utils'; // See: #8833. @@ -513,7 +514,7 @@ export default class FindAndReplaceFormView extends View { const inputElement = this._findInputView.fieldView.element; // Don't adjust the padding if the input (also: counter) were not rendered or not inserted into DOM yet. - if ( !inputElement || !inputElement.offsetParent ) { + if ( !inputElement || !isVisible( inputElement ) ) { return; } diff --git a/packages/ckeditor5-find-and-replace/tests/ui/findandreplaceformview.js b/packages/ckeditor5-find-and-replace/tests/ui/findandreplaceformview.js index a25d131232d..d08d8891cd7 100644 --- a/packages/ckeditor5-find-and-replace/tests/ui/findandreplaceformview.js +++ b/packages/ckeditor5-find-and-replace/tests/ui/findandreplaceformview.js @@ -44,9 +44,11 @@ describe( 'FindAndReplaceFormView', () => { beforeEach( () => { view = new FindAndReplaceFormView( { t: val => val } ); view.render(); + document.body.appendChild( view.element ); } ); afterEach( () => { + view.element.remove(); view.destroy(); } ); @@ -385,7 +387,7 @@ describe( 'FindAndReplaceFormView', () => { } ); it( 'should register child views\' #element in #focusTracker', () => { - view = new FindAndReplaceFormView( { t: val => val } ); + const view = new FindAndReplaceFormView( { t: val => val } ); const spy = testUtils.sinon.spy( view._focusTracker, 'add' ); @@ -399,16 +401,20 @@ describe( 'FindAndReplaceFormView', () => { sinon.assert.calledWithExactly( spy.getCall( 5 ), view._optionsDropdown.element ); sinon.assert.calledWithExactly( spy.getCall( 6 ), view._replaceButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 7 ), view._replaceAllButtonView.element ); + + view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - view = new FindAndReplaceFormView( { t: val => val } ); + const view = new FindAndReplaceFormView( { t: val => val } ); const spy = sinon.spy( view._keystrokes, 'listenTo' ); view.render(); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); } ); describe( 'activates keyboard navigation in the form', () => { diff --git a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js index 9d0b3836041..75c85ba15a1 100644 --- a/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js +++ b/packages/ckeditor5-image/tests/imageinsert/ui/imageinsertpanelview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Event */ +/* globals document, Event */ import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; @@ -33,9 +33,12 @@ describe( 'ImageUploadPanelView', () => { 'insertImageViaUrl': createLabeledInputView( { t: val => val } ) } ); view.render(); + document.body.appendChild( view.element ); } ); afterEach( () => { + view.element.remove(); + view.destroy(); sinon.restore(); } ); @@ -161,17 +164,19 @@ describe( 'ImageUploadPanelView', () => { } ); it( 'should register child views\' #element in #focusTracker with no integrations', () => { - view = new ImageUploadPanelView( { t: () => {} } ); + const view = new ImageUploadPanelView( { t: () => {} } ); const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); view.render(); sinon.assert.calledWithExactly( spy.getCall( 0 ), view.insertButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.cancelButtonView.element ); + + view.destroy(); } ); it( 'should register child views\' #element in #focusTracker with "insertImageViaUrl" integration', () => { - view = new ImageUploadPanelView( { t: () => {} }, { + const view = new ImageUploadPanelView( { t: () => {} }, { 'insertImageViaUrl': createLabeledInputView( { t: val => val } ) } ); @@ -182,16 +187,20 @@ describe( 'ImageUploadPanelView', () => { sinon.assert.calledWithExactly( spy.getCall( 0 ), view.getIntegration( 'insertImageViaUrl' ).element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.insertButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + + view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - view = new ImageUploadPanelView( { t: () => {} } ); + const view = new ImageUploadPanelView( { t: () => {} } ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); } ); it( 'intercepts the arrow* events and overrides the default toolbar behavior', () => { diff --git a/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js b/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js index b91c4dffe01..96d6f4e809a 100644 --- a/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js +++ b/packages/ckeditor5-image/tests/imagetextalternative/ui/textalternativeformview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global Event */ +/* global document, Event */ import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import TextAlternativeFormView from '../../../src/imagetextalternative/ui/textalternativeformview'; @@ -106,6 +106,12 @@ describe( 'TextAlternativeFormView', () => { describe( 'activates keyboard navigation in the form', () => { beforeEach( () => { view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); } ); it( 'so "tab" focuses the next focusable item', () => { diff --git a/packages/ckeditor5-link/tests/ui/linkactionsview.js b/packages/ckeditor5-link/tests/ui/linkactionsview.js index 054fb652d01..cf1ec69dfeb 100644 --- a/packages/ckeditor5-link/tests/ui/linkactionsview.js +++ b/packages/ckeditor5-link/tests/ui/linkactionsview.js @@ -3,6 +3,8 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ +/* globals document */ + import LinkActionsView from '../../src/ui/linkactionsview'; import View from '@ckeditor/ckeditor5-ui/src/view'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; @@ -20,6 +22,12 @@ describe( 'LinkActionsView', () => { beforeEach( () => { view = new LinkActionsView( { t: val => val } ); view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); } ); describe( 'constructor()', () => { @@ -139,22 +147,26 @@ describe( 'LinkActionsView', () => { it( 'should register child views\' #element in #focusTracker', () => { const spy = testUtils.sinon.spy( FocusTracker.prototype, 'add' ); - view = new LinkActionsView( { t: () => {} } ); + const view = new LinkActionsView( { t: () => {} } ); view.render(); sinon.assert.calledWithExactly( spy.getCall( 0 ), view.previewButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.editButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 2 ), view.unlinkButtonView.element ); + + view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - view = new LinkActionsView( { t: () => {} } ); + const view = new LinkActionsView( { t: () => {} } ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); } ); describe( 'activates keyboard navigation for the toolbar', () => { diff --git a/packages/ckeditor5-link/tests/ui/linkformview.js b/packages/ckeditor5-link/tests/ui/linkformview.js index 8429d5ca776..2d238c35690 100644 --- a/packages/ckeditor5-link/tests/ui/linkformview.js +++ b/packages/ckeditor5-link/tests/ui/linkformview.js @@ -29,9 +29,11 @@ describe( 'LinkFormView', () => { beforeEach( () => { view = new LinkFormView( { t: val => val }, { manualDecorators: [] } ); view.render(); + document.body.appendChild( view.element ); } ); afterEach( () => { + view.element.remove(); view.destroy(); } ); @@ -108,7 +110,7 @@ describe( 'LinkFormView', () => { } ); it( 'should register child views\' #element in #focusTracker', () => { - view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); + const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); @@ -117,16 +119,20 @@ describe( 'LinkFormView', () => { sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + + view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); + const view = new LinkFormView( { t: () => {} }, { manualDecorators: [] } ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); } ); describe( 'activates keyboard navigation for the toolbar', () => { @@ -214,6 +220,7 @@ describe( 'LinkFormView', () => { describe( 'manual decorators', () => { let view, collection, linkCommand; + beforeEach( () => { collection = new Collection(); collection.add( new ManualDecorator( { diff --git a/packages/ckeditor5-list/lang/contexts.json b/packages/ckeditor5-list/lang/contexts.json index 33cd5248b91..2281d7c83b4 100644 --- a/packages/ckeditor5-list/lang/contexts.json +++ b/packages/ckeditor5-list/lang/contexts.json @@ -21,5 +21,9 @@ "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." + "Upper-latin": "The tooltip text of the button that toggles the \"upper–latin\" list style.", + "List properties": "The label of the button that toggles the visibility of additional numbered list property UI fields.", + "Start at": "The label of the input allowing to change the start index of a numbered list.", + "Start index must be greater than 0.": "The error message displayed when the numbered list start index input value is invalid.", + "Reversed order": "The label of the switch button that reverses the order of the numbered list." } diff --git a/packages/ckeditor5-list/package.json b/packages/ckeditor5-list/package.json index 257401e27f4..9041b67ac91 100644 --- a/packages/ckeditor5-list/package.json +++ b/packages/ckeditor5-list/package.json @@ -12,7 +12,8 @@ ], "main": "src/index.js", "dependencies": { - "ckeditor5": "^31.1.0" + "ckeditor5": "^31.1.0", + "@ckeditor/ckeditor5-ui": "^31.1.0" }, "devDependencies": { "@ckeditor/ckeditor5-basic-styles": "^31.1.0", @@ -34,7 +35,6 @@ "@ckeditor/ckeditor5-table": "^31.1.0", "@ckeditor/ckeditor5-theme-lark": "^31.1.0", "@ckeditor/ckeditor5-typing": "^31.1.0", - "@ckeditor/ckeditor5-ui": "^31.1.0", "@ckeditor/ckeditor5-undo": "^31.1.0", "@ckeditor/ckeditor5-utils": "^31.1.0", "webpack": "^5.58.1", diff --git a/packages/ckeditor5-list/src/liststyleui.js b/packages/ckeditor5-list/src/liststyleui.js index 981dfcedadf..66a1d625cae 100644 --- a/packages/ckeditor5-list/src/liststyleui.js +++ b/packages/ckeditor5-list/src/liststyleui.js @@ -8,7 +8,9 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { ButtonView, SplitButtonView, createDropdown, addToolbarToDropdown } from 'ckeditor5/src/ui'; +import { ButtonView, SplitButtonView, createDropdown } from 'ckeditor5/src/ui'; + +import ListPropertiesView from './ui/listpropertiesview'; import bulletedListIcon from '../theme/icons/bulletedlist.svg'; import numberedListIcon from '../theme/icons/numberedlist.svg'; @@ -46,12 +48,12 @@ export default class ListStyleUI extends Plugin { const editor = this.editor; const t = editor.locale.t; - editor.ui.componentFactory.add( 'bulletedList', getSplitButtonCreator( { + editor.ui.componentFactory.add( 'bulletedList', getDropdownViewCreator( { editor, parentCommandName: 'bulletedList', buttonLabel: t( 'Bulleted List' ), buttonIcon: bulletedListIcon, - toolbarAriaLabel: t( 'Bulleted list styles toolbar' ), + styleGridAriaLabel: t( 'Bulleted list styles toolbar' ), styleDefinitions: [ { label: t( 'Toggle the disc list style' ), @@ -74,12 +76,12 @@ export default class ListStyleUI extends Plugin { ] } ) ); - editor.ui.componentFactory.add( 'numberedList', getSplitButtonCreator( { + editor.ui.componentFactory.add( 'numberedList', getDropdownViewCreator( { editor, parentCommandName: 'numberedList', buttonLabel: t( 'Numbered List' ), buttonIcon: numberedListIcon, - toolbarAriaLabel: t( 'Numbered list styles toolbar' ), + styleGridAriaLabel: t( 'Numbered list styles toolbar' ), styleDefinitions: [ { label: t( 'Toggle the decimal list style' ), @@ -131,39 +133,45 @@ export default class ListStyleUI extends Plugin { // 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 {String} options.styleGridAriaLabel The ARIA label for the styles grid 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 } ) { +function getDropdownViewCreator( { editor, parentCommandName, buttonLabel, buttonIcon, styleGridAriaLabel, 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 ) ); + const mainButtonView = dropdownView.buttonView; dropdownView.bind( 'isEnabled' ).to( parentCommand ); - dropdownView.toolbarView.ariaLabel = toolbarAriaLabel; dropdownView.class = 'ck-list-styles-dropdown'; - splitButtonView.on( 'execute', () => { + // Main button was clicked. + mainButtonView.on( 'execute', () => { editor.execute( parentCommandName ); editor.editing.view.focus(); } ); - splitButtonView.set( { + mainButtonView.set( { label: buttonLabel, icon: buttonIcon, tooltip: true, isToggleable: true } ); - splitButtonView.bind( 'isOn' ).to( parentCommand, 'value', value => !!value ); + mainButtonView.bind( 'isOn' ).to( parentCommand, 'value', value => !!value ); + + const listPropertiesView = createListPropertiesView( { + editor, + dropdownView, + parentCommandName, + styleGridAriaLabel, + styleDefinitions + } ); + + dropdownView.panelView.children.add( listPropertiesView ); return dropdownView; }; @@ -223,3 +231,73 @@ function getStyleButtonCreator( { editor, listStyleCommand, parentCommandName } return button; }; } + +// A helper that creates the properties view for the individual style dropdown. +// +// @param {Object} options +// @param {module:core/editor/editor~Editor} options.editor Editor instance. +// @param {module:ui/dropdown/dropdownview~DropdownView} options.dropdownView Styles dropdown view that hosts the properties view. +// @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 {Object} options.styleDefinitions Definitions of the style buttons. +// @param {String} options.styleGridAriaLabel An assistive technologies label set on the grid of styles (if the grid is rendered). +// @returns {module:list/ui/listpropertiesview~ListPropertiesView} +function createListPropertiesView( { + editor, + dropdownView, + parentCommandName, + styleDefinitions, + styleGridAriaLabel +} ) { + const locale = editor.locale; + const enabledProperties = editor.config.get( 'list.properties' ); + let styleButtonViews; + + if ( parentCommandName != 'numberedList' ) { + enabledProperties.startIndex = false; + enabledProperties.reversed = false; + } + + if ( enabledProperties.styles ) { + const listStyleCommand = editor.commands.get( 'listStyle' ); + + const styleButtonCreator = getStyleButtonCreator( { + editor, + parentCommandName, + listStyleCommand + } ); + + styleButtonViews = styleDefinitions.map( styleButtonCreator ); + } + + const listPropertiesView = new ListPropertiesView( locale, { + styleGridAriaLabel, + enabledProperties, + styleButtonViews + } ); + + if ( enabledProperties.startIndex ) { + const listStartCommand = editor.commands.get( 'listStart' ); + + listPropertiesView.startIndexFieldView.bind( 'isEnabled' ).to( listStartCommand ); + listPropertiesView.startIndexFieldView.fieldView.bind( 'value' ).to( listStartCommand ); + listPropertiesView.on( 'listStart', ( evt, data ) => editor.execute( 'listStart', data ) ); + } + + if ( enabledProperties.reversed ) { + const listReversedCommand = editor.commands.get( 'listReversed' ); + + listPropertiesView.reversedSwitchButtonView.bind( 'isEnabled' ).to( listReversedCommand ); + listPropertiesView.reversedSwitchButtonView.bind( 'isOn' ).to( listReversedCommand, 'value' ); + listPropertiesView.on( 'listReversed', () => { + const isReversed = listReversedCommand.value; + + editor.execute( 'listReversed', { reversed: !isReversed } ); + } ); + } + + // Make sure applying styles closes the dropdown. + listPropertiesView.delegate( 'execute' ).to( dropdownView ); + + return listPropertiesView; +} diff --git a/packages/ckeditor5-list/src/ui/collapsibleview.js b/packages/ckeditor5-list/src/ui/collapsibleview.js new file mode 100644 index 00000000000..97a29d3b14b --- /dev/null +++ b/packages/ckeditor5-list/src/ui/collapsibleview.js @@ -0,0 +1,152 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/ui/collapsibleview + */ + +import { View, ButtonView } from 'ckeditor5/src/ui'; + +// eslint-disable-next-line ckeditor5-rules/ckeditor-imports +import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; + +import '../../theme/collapsible.css'; + +/** + * A collapsible UI component. Consists of a labeled button and a container which can be collapsed + * by clicking the button. The collapsible container can be a host to other UI views. + * + * @protected + * @extends module:ui/view~View + */ +export default class CollapsibleView extends View { + /** + * Creates an instance of the collapsible view. + * + * @param {module:utils/locale~Locale} locale The {@link module:core/editor/editor~Editor#locale} instance. + * @param {Array.} [childViews] An optional array of initial child views to be inserted + * into the collapsible. + */ + constructor( locale, childViews ) { + super( locale ); + + const bind = this.bindTemplate; + + /** + * `true` when the container with {@link #children} is collapsed. `false` otherwise. + * + * @observable + * @member {Boolean} #isCollapsed + */ + this.set( 'isCollapsed', false ); + + /** + * The text label of the {@link #buttonView}. + * + * @observable + * @member {String} #label + * @default 'Show more' + */ + this.set( 'label', '' ); + + /** + * The main button that, when clicked, collapses or expands the container with {@link #children}. + * + * @readonly + * @member {module:ui/button/buttonview~ButtonView} #buttonView + */ + this.buttonView = this._createButtonView(); + + /** + * A collection of the child views that can be collapsed by clicking the {@link #buttonView}. + * + * @readonly + * @member {module:ui/viewcollection~ViewCollection} #children + */ + this.children = this.createCollection(); + + /** + * The id of the label inside the {@link #buttonView} that describes the collapsible + * container for assistive technologies. Set after the button was {@link #render rendered}. + * + * @private + * @readonly + * @observable + * @member {String} #_collapsibleAriaLabelUid + */ + this.set( '_collapsibleAriaLabelUid' ); + + if ( childViews ) { + this.children.addMany( childViews ); + } + + this.setTemplate( { + tag: 'div', + attributes: { + class: [ + 'ck', + 'ck-collapsible', + bind.if( 'isCollapsed', 'ck-collapsible_collapsed' ) + ] + }, + children: [ + this.buttonView, + { + tag: 'div', + attributes: { + class: [ + 'ck', + 'ck-collapsible__children' + ], + role: 'region', + hidden: bind.if( 'isCollapsed', 'hidden' ), + 'aria-labelledby': bind.to( '_collapsibleAriaLabelUid' ) + }, + children: this.children + } + ] + } ); + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + this._collapsibleAriaLabelUid = this.buttonView.labelView.element.id; + } + + /** + * Creates the main {@link #buttonView} of the collapsible. + * + * @private + * @returns {module:ui/button/buttonview~ButtonView} + */ + _createButtonView() { + const buttonView = new ButtonView( this.locale ); + const bind = buttonView.bindTemplate; + + buttonView.set( { + withText: true, + icon: dropdownArrowIcon + } ); + + buttonView.extendTemplate( { + attributes: { + 'aria-expanded': bind.to( 'isOn', value => String( value ) ) + } + } ); + + buttonView.bind( 'label' ).to( this ); + buttonView.bind( 'isOn' ).to( this, 'isCollapsed', isCollapsed => !isCollapsed ); + + buttonView.on( 'execute', () => { + this.isCollapsed = !this.isCollapsed; + } ); + + return buttonView; + } +} diff --git a/packages/ckeditor5-list/src/ui/inputnumberview.js b/packages/ckeditor5-list/src/ui/inputnumberview.js new file mode 100644 index 00000000000..a513818465d --- /dev/null +++ b/packages/ckeditor5-list/src/ui/inputnumberview.js @@ -0,0 +1,121 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/ui/inputnumberview + */ + +import { InputView } from 'ckeditor5/src/ui'; + +/** + * The number input view class. + * + * @protected + * @extends module:ui/input/inputview~InputView + */ +export default class InputNumberView extends InputView { + /** + * Creates an instance of the input number view. + * + * @param {module:utils/locale~Locale} locale The {@link module:core/editor/editor~Editor#locale} instance. + * @param {Object} [options] Options of the input. + * @param {Number} [options.min] The value of the `min` DOM attribute (the lowest accepted value). + * @param {Number} [options.max] The value of the `max` DOM attribute (the highest accepted value). + * @param {Number} [options.step] The value of the `step` DOM attribute. + */ + constructor( locale, { min, max, step } = {} ) { + super( locale ); + + const bind = this.bindTemplate; + + /** + * The value of the `min` DOM attribute (the lowest accepted value) set on the {@link #element}. + * + * @observable + * @default undefined + * @member {Number} #min + */ + this.set( 'min', min ); + + /** + * The value of the `max` DOM attribute (the highest accepted value) set on the {@link #element}. + * + * @observable + * @default undefined + * @member {Number} #max + */ + this.set( 'max', max ); + + /** + * The value of the `step` DOM attribute set on the {@link #element}. + * + * @observable + * @default undefined + * @member {Number} #step + */ + this.set( 'step', step ); + + this.extendTemplate( { + attributes: { + type: 'number', + class: [ + 'ck-input-number' + ], + min: bind.to( 'min' ), + max: bind.to( 'max' ), + step: bind.to( 'step' ) + } + } ); + } +} + +/** + * A helper for creating labeled number inputs. + * + * It creates an instance of a {@link module:list/ui/inputnumberview~InputNumberView input number} that is + * logically related to a {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView labeled view} in DOM. + * + * The helper does the following: + * + * * It sets input's `id` and `ariaDescribedById` attributes. + * * It binds input's `isReadOnly` to the labeled view. + * * It binds input's `hasError` to the labeled view. + * * It enables a logic that cleans up the error when user starts typing in the input. + * + * Usage: + * + * const labeledInputView = new LabeledFieldView( locale, createLabeledInputNumber ); + * console.log( labeledInputView.fieldView ); // A number input instance. + * + * @protected + * @param {module:ui/labeledfield/labeledfieldview~LabeledFieldView} labeledFieldView The instance of the labeled field view. + * @param {String} viewUid An UID string that allows DOM logical connection between the + * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the input. + * @param {String} statusUid An UID string that allows DOM logical connection between the + * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input. + * @returns {module:ui/inputtext/inputtextview~InputTextView} The input text view instance. + */ +export function createLabeledInputNumber( labeledFieldView, viewUid, statusUid ) { + const inputView = new InputNumberView( labeledFieldView.locale ); + + inputView.set( { + id: viewUid, + ariaDescribedById: statusUid, + inputMode: 'numeric' + } ); + + inputView.bind( 'isReadOnly' ).to( labeledFieldView, 'isEnabled', value => !value ); + inputView.bind( 'hasError' ).to( labeledFieldView, 'errorText', value => !!value ); + + inputView.on( 'input', () => { + // UX: Make the error text disappear and disable the error indicator as the user + // starts fixing the errors. + labeledFieldView.errorText = null; + } ); + + labeledFieldView.bind( 'isEmpty', 'isFocused', 'placeholder' ).to( inputView ); + + return inputView; +} diff --git a/packages/ckeditor5-list/src/ui/listpropertiesview.js b/packages/ckeditor5-list/src/ui/listpropertiesview.js new file mode 100644 index 00000000000..0ce56d88f25 --- /dev/null +++ b/packages/ckeditor5-list/src/ui/listpropertiesview.js @@ -0,0 +1,405 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module list/ui/listpropertiesview + */ + +import { + View, + ViewCollection, + FocusCycler, + SwitchButtonView, + LabeledFieldView +} from 'ckeditor5/src/ui'; +import { + FocusTracker, + KeystrokeHandler +} from 'ckeditor5/src/utils'; + +import { createLabeledInputNumber } from './inputnumberview'; +import CollapsibleView from './collapsibleview'; + +import '../../theme/listproperties.css'; + +/** + * The list properties view to be displayed in the list dropdown. + * + * Contains a grid of available list styles and, for numbered list, also the list start index and reversed fields. + * + * @extends module:ui/view~View + */ +export default class ListPropertiesView extends View { + /** + * Creates an instance of the list properties view. + * + * @param {module:utils/locale~Locale} locale The {@link module:core/editor/editor~Editor#locale} instance. + * @param {Object} options Options of the view + * @param {Object.} options.enabledProperties An object containing the configuration of enabled list property names. + * Allows conditional rendering the sub-components of the properties view. + * @param {Array.|null} options.styleButtonViews A list of style buttons to be rendered + * inside the styles grid. The grid will not be rendered when `enabledProperties` does not include the `'styles'` key. + * @param {String} options.styleGridAriaLabel An assistive technologies label set on the grid of styles (if the grid is rendered). + */ + constructor( locale, { enabledProperties, styleButtonViews, styleGridAriaLabel } ) { + super( locale ); + + const elementCssClasses = [ + 'ck', + 'ck-list-properties' + ]; + + /** + * A collection of the child views. + * + * @readonly + * @member {module:ui/viewcollection~ViewCollection} + */ + this.children = this.createCollection(); + + /** + * A view that renders the grid of list styles. + * + * @readonly + * @member {module:ui/view~View|null} + */ + this.stylesView = null; + + /** + * A collapsible view that hosts additional list property fields ({@link #startIndexFieldView} and + * {@link #reversedSwitchButtonView}) to visually separate them from the {@link #stylesView grid of styles}. + * + * **Note**: Only present when + * * the view represents **numbered** list properties, + * * and the {@link #stylesView} is rendered, + * * and either {@link #startIndexFieldView} or {@link #reversedSwitchButtonView} is rendered. + * + * @readonly + * @member {module:list/ui/collapsibleview~CollapsibleView|null} + */ + this.additionalPropertiesCollapsibleView = null; + + /** + * A labeled number field allowing the user to set the start index of the list. + * + * **Note**: Only present when the view represents **numbered** list properties. + * + * @readonly + * @member {module:ui/labeledfield/labeledfieldview~LabeledFieldView|null} + */ + this.startIndexFieldView = null; + + /** + * A switch button allowing the user to make the edited list reversed. + * + * **Note**: Only present when the view represents **numbered** list properties. + * + * @readonly + * @member {module:ui/button/switchbuttonview~SwitchButtonView|null} + */ + this.reversedSwitchButtonView = null; + + /** + * Tracks information about the DOM focus in the view. + * + * @readonly + * @member {module:utils/focustracker~FocusTracker} + */ + this.focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + * + * @readonly + * @member {module:utils/keystrokehandler~KeystrokeHandler} + */ + this.keystrokes = new KeystrokeHandler(); + + /** + * A collection of views that can be focused in the properties view. + * + * @readonly + * @member {module:ui/viewcollection~ViewCollection} + */ + this.focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #focusables} in the view. + * + * @readonly + * @protected + * @member {module:ui/focuscycler~FocusCycler} + */ + this.focusCycler = new FocusCycler( { + focusables: this.focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate #children backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate #children forwards using the Tab key. + focusNext: 'tab' + } + } ); + + // The rendering of the styles grid is conditional. When there is no styles grid, the view will render without collapsible + // for numbered list properties, hence simplifying the layout. + if ( enabledProperties.styles ) { + this.stylesView = this._createStylesView( styleButtonViews, styleGridAriaLabel ); + this.children.add( this.stylesView ); + } else { + elementCssClasses.push( 'ck-list-properties_without-styles' ); + } + + // The rendering of the numbered list property views is also conditional. It only makes sense for the numbered list + // dropdown. The unordered list does not have such properties. + if ( enabledProperties.startIndex || enabledProperties.reversed ) { + this._addNumberedListPropertyViews( enabledProperties, styleButtonViews ); + + elementCssClasses.push( 'ck-list-properties_with-numbered-properties' ); + } + + this.setTemplate( { + tag: 'div', + attributes: { + class: elementCssClasses + }, + children: this.children + } ); + } + + /** + * @inheritDoc + */ + render() { + super.render(); + + if ( this.stylesView ) { + for ( const styleButtonView of this.stylesView.children ) { + // Register the view as focusable. + this.focusables.add( styleButtonView ); + + // Register the view in the focus tracker. + this.focusTracker.add( styleButtonView.element ); + } + + // Register the collapsible toggle button to the focus system. + if ( this.startIndexFieldView || this.reversedSwitchButtonView ) { + this.focusables.add( this.children.last.buttonView ); + this.focusTracker.add( this.children.last.buttonView.element ); + } + } + + if ( this.startIndexFieldView ) { + this.focusables.add( this.startIndexFieldView ); + this.focusTracker.add( this.startIndexFieldView.element ); + + // Intercept the `selectstart` event, which is blocked by default because of the default behavior + // of the DropdownView#panelView. + // TODO: blocking `selectstart` in the #panelView should be configurable per–drop–down instance. + this.listenTo( this.startIndexFieldView.element, 'selectstart', ( evt, domEvt ) => { + domEvt.stopPropagation(); + }, { priority: 'high' } ); + + const stopPropagation = data => data.stopPropagation(); + + // Since the form is in the dropdown panel which is a child of the toolbar, the toolbar's + // keystroke handler would take over the key management in the input. We need to prevent + // this ASAP. Otherwise, the basic caret movement using the arrow keys will be impossible. + this.keystrokes.set( 'arrowright', stopPropagation ); + this.keystrokes.set( 'arrowleft', stopPropagation ); + this.keystrokes.set( 'arrowup', stopPropagation ); + this.keystrokes.set( 'arrowdown', stopPropagation ); + } + + if ( this.reversedSwitchButtonView ) { + this.focusables.add( this.reversedSwitchButtonView ); + this.focusTracker.add( this.reversedSwitchButtonView.element ); + } + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element ); + } + + /** + * @inheritDoc + */ + focus() { + this.focusCycler.focusFirst(); + } + + /** + * @inheritDoc + */ + focusLast() { + this.focusCycler.focusLast(); + } + + /** + * @inheritDoc + */ + destroy() { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Creates the list styles grid. + * + * @protected + * @param {Array.} styleButtons Buttons to be placed in the grid. + * @param {String} styleGridAriaLabel The accessible technology label of the grid. + * @returns {module:ui/view~View} + */ + _createStylesView( styleButtons, styleGridAriaLabel ) { + const stylesView = new View( this.locale ); + + stylesView.children = stylesView.createCollection( this.locale ); + stylesView.children.addMany( styleButtons ); + + stylesView.setTemplate( { + tag: 'div', + attributes: { + 'aria-label': styleGridAriaLabel, + class: [ + 'ck', + 'ck-list-styles-list' + ] + }, + children: stylesView.children + } ); + + stylesView.children.delegate( 'execute' ).to( this ); + + return stylesView; + } + + /** + * Renders {@link #startIndexFieldView} and/or {@link #reversedSwitchButtonView} depending on the configuration of the properties view. + * + * @private + * @param {Object.} options.enabledProperties An object containing the configuration of enabled list property names + * (see {@link #constructor}). + */ + _addNumberedListPropertyViews( enabledProperties ) { + const t = this.locale.t; + const numberedPropertyViews = []; + + if ( enabledProperties.startIndex ) { + this.startIndexFieldView = this._createStartIndexField(); + numberedPropertyViews.push( this.startIndexFieldView ); + } + + if ( enabledProperties.reversed ) { + this.reversedSwitchButtonView = this._createReversedSwitchButton(); + numberedPropertyViews.push( this.reversedSwitchButtonView ); + } + + // When there are some style buttons, pack the numbered list properties into a collapsible to separate them. + if ( enabledProperties.styles ) { + this.additionalPropertiesCollapsibleView = new CollapsibleView( this.locale, numberedPropertyViews ); + + this.additionalPropertiesCollapsibleView.set( { + label: t( 'List properties' ), + isCollapsed: true + } ); + + // Don't enable the collapsible view unless either start index or reversed field is enabled (e.g. when no list is selected). + this.additionalPropertiesCollapsibleView.buttonView.bind( 'isEnabled' ).toMany( + numberedPropertyViews, 'isEnabled', ( ...areEnabled ) => areEnabled.some( isEnabled => isEnabled ) ); + + // Automatically collapse the additional properties collapsible when either start index or reversed field gets disabled. + this.additionalPropertiesCollapsibleView.buttonView.on( 'change:isEnabled', ( evt, data, isEnabled ) => { + if ( !isEnabled ) { + this.additionalPropertiesCollapsibleView.isCollapsed = true; + } + } ); + + this.children.add( this.additionalPropertiesCollapsibleView ); + } else { + this.children.addMany( numberedPropertyViews ); + } + } + + /** + * Creates the list start index labeled field. + * + * @private + * @protected + * @returns {module:ui/labeledfield/labeledfieldview~LabeledFieldView} + */ + _createStartIndexField() { + const t = this.locale.t; + const startIndexFieldView = new LabeledFieldView( this.locale, createLabeledInputNumber ); + + startIndexFieldView.set( { + label: t( 'Start at' ), + class: 'ck-numbered-list-properties__start-index' + } ); + + startIndexFieldView.fieldView.set( { + min: 1, + step: 1, + value: 1, + inputMode: 'numeric' + } ); + + startIndexFieldView.fieldView.on( 'input', () => { + const inputElement = startIndexFieldView.fieldView.element; + const startIndex = inputElement.valueAsNumber; + + if ( Number.isNaN( startIndex ) ) { + return; + } + + if ( !inputElement.checkValidity() ) { + startIndexFieldView.errorText = t( 'Start index must be greater than 0.' ); + } else { + this.fire( 'listStart', { startIndex } ); + } + } ); + + return startIndexFieldView; + } + + /** + * Creates the list reversed switch button. + * + * @private + * @protected + * @returns {module:ui/button/switchbuttonview~SwitchButtonView} + */ + _createReversedSwitchButton() { + const t = this.locale.t; + const reversedButtonView = new SwitchButtonView( this.locale ); + + reversedButtonView.set( { + withText: true, + label: t( 'Reversed order' ), + class: 'ck-numbered-list-properties__reversed-order' + } ); + + reversedButtonView.delegate( 'execute' ).to( this, 'listReversed' ); + + return reversedButtonView; + } + + /** + * Fired when the list start index value has changed via {@link #startIndexFieldView}. + * + * @event listStart + * @param {Object} data + * @param {Number} data.startIndex The new start index of the list. + */ + + /** + * Fired when the list order has changed (reversed) via {@link #reversedSwitchButtonView}. + * + * @event listReversed + */ +} diff --git a/packages/ckeditor5-list/tests/liststyleui.js b/packages/ckeditor5-list/tests/liststyleui.js index e4cdf01698e..09dc64cca98 100644 --- a/packages/ckeditor5-list/tests/liststyleui.js +++ b/packages/ckeditor5-list/tests/liststyleui.js @@ -5,19 +5,17 @@ /* 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 { Paragraph } from '@ckeditor/ckeditor5-paragraph'; +import { BlockQuote } from '@ckeditor/ckeditor5-block-quote'; +import { UndoEditing } from '@ckeditor/ckeditor5-undo'; import DropdownView from '@ckeditor/ckeditor5-ui/src/dropdown/dropdownview'; -import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting'; +import { View, ButtonView, LabeledFieldView, SwitchButtonView } from '@ckeditor/ckeditor5-ui'; 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'; @@ -28,20 +26,31 @@ import listStyleUpperRomanIcon from '../theme/icons/liststyleupperroman.svg'; import listStyleLowerLatinIcon from '../theme/icons/liststylelowerlatin.svg'; import listStyleUpperLatinIcon from '../theme/icons/liststyleupperlatin.svg'; +import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model'; +import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor'; + describe( 'ListStyleUI', () => { - let editorElement, editor, model, listStyleCommand; + let editorElement, editor, model, listStyleCommand, listPropertiesView; 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' ); - } ); + return ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ], + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ).then( newEditor => { + editor = newEditor; + model = editor.model; + + listStyleCommand = editor.commands.get( 'listStyle' ); + } ); } ); afterEach( () => { @@ -65,6 +74,7 @@ describe( 'ListStyleUI', () => { beforeEach( () => { bulletedListCommand = editor.commands.get( 'bulletedList' ); bulletedListDropdown = editor.ui.componentFactory.create( 'bulletedList' ); + listPropertiesView = bulletedListDropdown.panelView.children.first; } ); it( 'should registered as "bulletedList" in the component factory', () => { @@ -85,6 +95,12 @@ describe( 'ListStyleUI', () => { expect( bulletedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' ); } ); + it( 'should not have numbered list properties', () => { + expect( listPropertiesView.stylesView ).to.be.instanceOf( View ); + expect( listPropertiesView.startIndexFieldView ).to.be.null; + expect( listPropertiesView.reversedSwitchButtonView ).to.be.null; + } ); + describe( 'main split button', () => { let mainButtonView; @@ -129,23 +145,19 @@ describe( 'ListStyleUI', () => { } ); } ); - describe( 'toolbar with style buttons', () => { - let toolbarView; + describe( 'grid with style buttons', () => { + let stylesView; beforeEach( () => { - toolbarView = bulletedListDropdown.toolbarView; - } ); - - it( 'should be in the dropdown panel', () => { - expect( bulletedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView ); + stylesView = listPropertiesView.stylesView; } ); it( 'should have a proper ARIA label', () => { - expect( toolbarView.ariaLabel ).to.equal( 'Bulleted list styles toolbar' ); + expect( stylesView.element.getAttribute( 'aria-label' ) ).to.equal( 'Bulleted list styles toolbar' ); } ); it( 'should bring the "disc" list style button', () => { - const buttonView = toolbarView.items.get( 0 ); + const buttonView = stylesView.children.first; expect( buttonView.label ).to.equal( 'Toggle the disc list style' ); expect( buttonView.tooltip ).to.equal( 'Disc' ); @@ -153,7 +165,7 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "circle" list style button', () => { - const buttonView = toolbarView.items.get( 1 ); + const buttonView = stylesView.children.get( 1 ); expect( buttonView.label ).to.equal( 'Toggle the circle list style' ); expect( buttonView.tooltip ).to.equal( 'Circle' ); @@ -161,19 +173,28 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "square" list style button', () => { - const buttonView = toolbarView.items.get( 2 ); + const buttonView = stylesView.children.get( 2 ); expect( buttonView.label ).to.equal( 'Toggle the square list style' ); expect( buttonView.tooltip ).to.equal( 'Square' ); expect( buttonView.icon ).to.equal( listStyleSquareIcon ); } ); + it( 'should close the drop-down when any button gets executed', () => { + const spy = sinon.spy(); + + bulletedListDropdown.on( 'execute', spy ); + listPropertiesView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + describe( 'style button', () => { let styleButtonView; beforeEach( () => { // "circle" - styleButtonView = toolbarView.items.get( 1 ); + styleButtonView = stylesView.children.get( 1 ); sinon.spy( editor, 'execute' ); sinon.spy( editor.editing.view, 'focus' ); @@ -262,6 +283,7 @@ describe( 'ListStyleUI', () => { beforeEach( () => { numberedListCommand = editor.commands.get( 'numberedList' ); numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' ); + listPropertiesView = numberedListDropdown.panelView.children.first; } ); it( 'should registered as "numberedList" in the component factory', () => { @@ -282,6 +304,86 @@ describe( 'ListStyleUI', () => { expect( numberedListDropdown.class ).to.equal( 'ck-list-styles-dropdown' ); } ); + describe( 'support of config.list.properties', () => { + it( 'should have styles grid, start index, and reversed fields when all properties are enabled in the config', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ], + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ); + + const numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' ); + const listPropertiesView = numberedListDropdown.panelView.children.first; + + expect( listPropertiesView.stylesView ).to.be.instanceOf( View ); + expect( listPropertiesView.startIndexFieldView ).to.be.instanceOf( LabeledFieldView ); + expect( listPropertiesView.reversedSwitchButtonView ).to.be.instanceOf( SwitchButtonView ); + + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'should have only the styles grid when start index and reversed properties are disabled', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ], + list: { + properties: { + styles: true, + startIndex: false, + reversed: false + } + } + } ); + + const numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' ); + const listPropertiesView = numberedListDropdown.panelView.children.first; + + expect( listPropertiesView.stylesView ).to.be.instanceOf( View ); + expect( listPropertiesView.startIndexFieldView ).to.be.null; + expect( listPropertiesView.reversedSwitchButtonView ).to.be.null; + + editorElement.remove(); + await editor.destroy(); + } ); + + it( 'should have only the numbered list property UI when styles are disabled', async () => { + const editorElement = document.createElement( 'div' ); + document.body.appendChild( editorElement ); + + const editor = await ClassicTestEditor.create( editorElement, { + plugins: [ Paragraph, BlockQuote, ListStyle, UndoEditing ], + list: { + properties: { + styles: false, + startIndex: true, + reversed: true + } + } + } ); + + const numberedListDropdown = editor.ui.componentFactory.create( 'numberedList' ); + const listPropertiesView = numberedListDropdown.panelView.children.first; + + expect( listPropertiesView.stylesView ).to.be.null; + expect( listPropertiesView.startIndexFieldView ).to.be.instanceOf( LabeledFieldView ); + expect( listPropertiesView.reversedSwitchButtonView ).to.be.instanceOf( SwitchButtonView ); + + editorElement.remove(); + await editor.destroy(); + } ); + } ); + describe( 'main split button', () => { let mainButtonView; @@ -326,23 +428,19 @@ describe( 'ListStyleUI', () => { } ); } ); - describe( 'toolbar with style buttons', () => { - let toolbarView; + describe( 'grid with style buttons', () => { + let stylesView; beforeEach( () => { - toolbarView = numberedListDropdown.toolbarView; - } ); - - it( 'should be in the dropdown panel', () => { - expect( numberedListDropdown.panelView.children.get( 0 ) ).to.equal( toolbarView ); + stylesView = listPropertiesView.stylesView; } ); it( 'should have a proper ARIA label', () => { - expect( toolbarView.ariaLabel ).to.equal( 'Numbered list styles toolbar' ); + expect( stylesView.element.getAttribute( 'aria-label' ) ).to.equal( 'Numbered list styles toolbar' ); } ); it( 'should bring the "decimal" list style button', () => { - const buttonView = toolbarView.items.get( 0 ); + const buttonView = stylesView.children.first; expect( buttonView.label ).to.equal( 'Toggle the decimal list style' ); expect( buttonView.tooltip ).to.equal( 'Decimal' ); @@ -350,7 +448,7 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "decimal-leading-zero" list style button', () => { - const buttonView = toolbarView.items.get( 1 ); + const buttonView = stylesView.children.get( 1 ); expect( buttonView.label ).to.equal( 'Toggle the decimal with leading zero list style' ); expect( buttonView.tooltip ).to.equal( 'Decimal with leading zero' ); @@ -358,7 +456,7 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "lower-roman" list style button', () => { - const buttonView = toolbarView.items.get( 2 ); + const buttonView = stylesView.children.get( 2 ); expect( buttonView.label ).to.equal( 'Toggle the lower–roman list style' ); expect( buttonView.tooltip ).to.equal( 'Lower–roman' ); @@ -366,7 +464,7 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "upper-roman" list style button', () => { - const buttonView = toolbarView.items.get( 3 ); + const buttonView = stylesView.children.get( 3 ); expect( buttonView.label ).to.equal( 'Toggle the upper–roman list style' ); expect( buttonView.tooltip ).to.equal( 'Upper-roman' ); @@ -374,7 +472,7 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "lower–latin" list style button', () => { - const buttonView = toolbarView.items.get( 4 ); + const buttonView = stylesView.children.get( 4 ); expect( buttonView.label ).to.equal( 'Toggle the lower–latin list style' ); expect( buttonView.tooltip ).to.equal( 'Lower-latin' ); @@ -382,19 +480,28 @@ describe( 'ListStyleUI', () => { } ); it( 'should bring the "upper–latin" list style button', () => { - const buttonView = toolbarView.items.get( 5 ); + const buttonView = stylesView.children.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 ); } ); + it( 'should close the drop-down when any button gets executed', () => { + const spy = sinon.spy(); + + numberedListDropdown.on( 'execute', spy ); + listPropertiesView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + describe( 'style button', () => { let styleButtonView; beforeEach( () => { // "decimal-leading-zero"" - styleButtonView = toolbarView.items.get( 1 ); + styleButtonView = stylesView.children.get( 1 ); sinon.spy( editor, 'execute' ); sinon.spy( editor.editing.view, 'focus' ); @@ -464,7 +571,14 @@ describe( 'ListStyleUI', () => { styleButtonView.fire( 'execute' ); expect( getData( model ) ).to.equal( - 'foo[]' + '' + + 'foo[]' + + '' ); editor.execute( 'undo' ); @@ -475,6 +589,74 @@ describe( 'ListStyleUI', () => { } ); } ); } ); + + describe( 'list start input', () => { + let listStartCommand, startIndexFieldView; + + beforeEach( () => { + listStartCommand = editor.commands.get( 'listStart' ); + startIndexFieldView = listPropertiesView.startIndexFieldView; + } ); + + it( 'should bind #isEnabled to the list start command', () => { + listStartCommand.isEnabled = true; + expect( startIndexFieldView.isEnabled ).to.be.true; + + listStartCommand.isEnabled = false; + expect( startIndexFieldView.isEnabled ).to.be.false; + } ); + + it( 'should bind #value to the list start command', () => { + listStartCommand.value = 123; + expect( startIndexFieldView.fieldView.value ).to.equal( 123 ); + + listStartCommand.value = 321; + expect( startIndexFieldView.fieldView.value ).to.equal( 321 ); + } ); + + it( 'should execute the list start command when the list property view fires #listStart', () => { + const spy = sinon.spy( editor, 'execute' ); + + listPropertiesView.fire( 'listStart', { startIndex: 1234 } ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, 'listStart', { startIndex: 1234 } ); + } ); + } ); + + describe( 'list reversed switch button', () => { + let listReversedCommand, reversedSwitchButtonView; + + beforeEach( () => { + listReversedCommand = editor.commands.get( 'listReversed' ); + reversedSwitchButtonView = listPropertiesView.reversedSwitchButtonView; + } ); + + it( 'should bind #isEnabled to the list reversed command', () => { + listReversedCommand.isEnabled = true; + expect( reversedSwitchButtonView.isEnabled ).to.be.true; + + listReversedCommand.isEnabled = false; + expect( reversedSwitchButtonView.isEnabled ).to.be.false; + } ); + + it( 'should bind #isOn to the list reversed command', () => { + listReversedCommand.value = true; + expect( reversedSwitchButtonView.isOn ).to.be.true; + + listReversedCommand.value = false; + expect( reversedSwitchButtonView.isOn ).to.be.false; + } ); + + it( 'should execute the list reversed command when the list property view fires #listReversed', () => { + const spy = sinon.spy( editor, 'execute' ); + + listPropertiesView.fire( 'listReversed' ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, 'listReversed', { reversed: true } ); + } ); + } ); } ); } ); } ); diff --git a/packages/ckeditor5-list/tests/manual/list-properties.html b/packages/ckeditor5-list/tests/manual/list-properties.html new file mode 100644 index 00000000000..e2a9640056f --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/list-properties.html @@ -0,0 +1,25 @@ +

Styles and properties

+ +

Styles + Start index + Reversed

+
+ +

Styles + Start index

+
+ +

Styles + Reversed

+
+ +

Only properties

+ +

Start index + Reversed

+
+ +

Start index

+
+ +

Reversed

+
+ +

No properties, just styles

+ +
diff --git a/packages/ckeditor5-list/tests/manual/list-properties.js b/packages/ckeditor5-list/tests/manual/list-properties.js new file mode 100644 index 00000000000..fe0e18e108b --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/list-properties.js @@ -0,0 +1,183 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals console, 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'; + +const config = { + initialData: ` +

Ordered list

+
    +
  1. First item
  2. +
  3. Second item
  4. +
  5. Third item
  6. +
+

Unordered list

+
    +
  • First item
  • +
  • Second item
  • +
  • Third item
  • +
+ `, + plugins: [ + Essentials, + Bold, + Italic, + Code, + Heading, + List, + TodoList, + Paragraph, + ListStyle, + Table, + TablePropertiesEditing, + TableCellPropertiesEditing, + Indent, + IndentBlock, + RemoveFormat + ], + toolbar: [ + 'numberedList', + '|', + 'bulletedList', 'todoList', + '|', + 'heading', + '|', + 'bold', + 'italic', + '|', + 'removeFormat', + '|', + 'outdent', + 'indent', + '|', + 'undo', 'redo' + ] +}; + +ClassicEditor + .create( document.querySelector( '#editor-a' ), { + ...config, + list: { + properties: { + styles: true, + startIndex: true, + reversed: true + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +ClassicEditor + .create( document.querySelector( '#editor-b' ), { + ...config, + list: { + properties: { + styles: true, + startIndex: true, + reversed: false + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +ClassicEditor + .create( document.querySelector( '#editor-c' ), { + ...config, + list: { + properties: { + styles: true, + startIndex: false, + reversed: true + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +// ------------------------------------------------------------------ + +ClassicEditor + .create( document.querySelector( '#editor-d' ), { + ...config, + list: { + properties: { + styles: false, + startIndex: true, + reversed: true + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +ClassicEditor + .create( document.querySelector( '#editor-e' ), { + ...config, + list: { + properties: { + styles: false, + startIndex: true, + reversed: false + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +ClassicEditor + .create( document.querySelector( '#editor-f' ), { + ...config, + list: { + properties: { + styles: false, + startIndex: false, + reversed: true + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); + +// ------------------------------------------------------------------ + +ClassicEditor + .create( document.querySelector( '#editor-g' ), { + ...config, + list: { + properties: { + styles: true, + startIndex: false, + reversed: false + } + } + } ) + .catch( err => { + console.error( err.stack ); + } ); diff --git a/packages/ckeditor5-list/tests/manual/list-properties.md b/packages/ckeditor5-list/tests/manual/list-properties.md new file mode 100644 index 00000000000..f0eb231664e --- /dev/null +++ b/packages/ckeditor5-list/tests/manual/list-properties.md @@ -0,0 +1,18 @@ +# List properties feature + +Several editors were configured in this manual. + +## Basics + +1. Open the numbered list dropdown in each editor. +2. Make sure the UI of the dropdown matches the description of the editor. +3. Open the bulleted list dropdown and make sure it always looks the same. + +## Accessibility + +1. In each editor, focus the editing root and hit (Fn+)Alt+F10. +2. Hit arrow down when the numbered list dropdown is highlighted. +3. Hit arrow down (or up) again to focus the first (last) item in the dropdown. +4. Navigate using Tab across the UI. +5. Make sure the navigation works both ways by using Shift+Tab. +6. Make sure you can enter numbered list properties when collapsed. diff --git a/packages/ckeditor5-list/tests/manual/list-style.md b/packages/ckeditor5-list/tests/manual/list-style.md index d675164f57e..c739dffe535 100644 --- a/packages/ckeditor5-list/tests/manual/list-style.md +++ b/packages/ckeditor5-list/tests/manual/list-style.md @@ -13,5 +13,5 @@ The editor should display 4 headers and tables. 4. **Nested/mixed lists** A table with two columns that contains nested and mixed `
    ` and `
      ` 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. diff --git a/packages/ckeditor5-list/tests/ui/collapsibleview.js b/packages/ckeditor5-list/tests/ui/collapsibleview.js new file mode 100644 index 00000000000..6eb848077bc --- /dev/null +++ b/packages/ckeditor5-list/tests/ui/collapsibleview.js @@ -0,0 +1,147 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import CollapsibleView from '../../src/ui/collapsibleview'; + +import { ButtonView, ViewCollection } from '@ckeditor/ckeditor5-ui'; +import dropdownArrowIcon from '@ckeditor/ckeditor5-ui/theme/icons/dropdown-arrow.svg'; + +describe( 'CollapsibleView', () => { + let view, locale; + + beforeEach( () => { + locale = { t: text => text }; + view = new CollapsibleView( locale ); + + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should accept initial list of children', () => { + view.destroy(); + + const buttonA = new ButtonView( locale ); + const buttonB = new ButtonView( locale ); + + buttonA.class = 'foo'; + buttonB.class = 'bar'; + + view = new CollapsibleView( locale, [ buttonA, buttonB ] ); + view.render(); + + expect( view.element.lastChild.firstChild.classList.contains( 'foo' ) ).to.be.true; + expect( view.element.lastChild.lastChild.classList.contains( 'bar' ) ).to.be.true; + } ); + + describe( 'template', () => { + it( 'should create an #element from the template', () => { + expect( view.element.tagName ).to.equal( 'DIV' ); + expect( view.element.classList.contains( 'ck-collapsible' ) ).to.be.true; + + expect( view.element.firstChild.classList.contains( 'ck-button' ) ).to.be.true; + expect( view.element.lastChild.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.lastChild.classList.contains( 'ck-collapsible__children' ) ).to.be.true; + expect( view.element.lastChild.getAttribute( 'role' ) ).to.equal( 'region' ); + } ); + + describe( 'main button', () => { + it( 'should have an icon', () => { + expect( view.buttonView.icon ).to.equal( dropdownArrowIcon ); + } ); + + it( 'should display its text', () => { + expect( view.buttonView.withText ).to.be.true; + } ); + } ); + + it( 'should set the proper ARIA label on the collapsible container', () => { + const buttonLabelId = view.buttonView.labelView.element.id; + + expect( view.element.lastChild.getAttribute( 'aria-labelledby' ) ).to.match( /^ck-editor__aria/ ); + expect( view.element.lastChild.getAttribute( 'aria-labelledby' ) ).to.equal( buttonLabelId ); + } ); + } ); + + it( 'should have a #children collection', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #isCollapsed', () => { + expect( view.isCollapsed ).to.be.false; + } ); + + it( 'should have #label with default value', () => { + expect( view.label ).to.equal( '' ); + } ); + } ); + + describe( 'DOM bindings', () => { + describe( 'button label', () => { + it( 'should react on view#label', () => { + expect( view.buttonView.element.innerText ).to.equal( '' ); + + view.label = 'Foo'; + + expect( view.buttonView.element.innerText ).to.equal( 'Foo' ); + } ); + } ); + + describe( 'button aria-expanded', () => { + it( 'should react on button#isOn', () => { + expect( view.buttonView.isOn ).to.be.true; + expect( view.buttonView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'true' ); + + view.buttonView.isOn = false; + expect( view.buttonView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'false' ); + } ); + + it( 'should react on view#isCollapsed', () => { + expect( view.buttonView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'true' ); + + view.isCollapsed = true; + expect( view.buttonView.element.getAttribute( 'aria-expanded' ) ).to.equal( 'false' ); + } ); + } ); + + describe( 'collapsed state', () => { + it( 'should react on view#isCollapsed', () => { + expect( view.element.classList.contains( 'ck-collapsible_collapsed' ) ).to.be.false; + expect( view.element.lastChild.getAttribute( 'hidden' ) ).to.be.null; + + view.isCollapsed = true; + + expect( view.element.classList.contains( 'ck-collapsible_collapsed' ) ).to.be.true; + expect( view.element.lastChild.getAttribute( 'hidden' ) ).to.equal( 'hidden' ); + } ); + + it( 'should react on view.buttonView#execute', () => { + expect( view.element.classList.contains( 'ck-collapsible_collapsed' ) ).to.be.false; + + view.buttonView.fire( 'execute' ); + + expect( view.element.classList.contains( 'ck-collapsible_collapsed' ) ).to.be.true; + } ); + } ); + + describe( 'collapsible children', () => { + it( 'should react to changes in #children', () => { + const buttonA = new ButtonView( locale ); + const buttonB = new ButtonView( locale ); + + expect( view.element.lastChild.children.length ).to.equal( 0 ); + + view.children.add( buttonA ); + expect( view.element.lastChild.children.length ).to.equal( 1 ); + + view.children.add( buttonB ); + expect( view.element.lastChild.children.length ).to.equal( 2 ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/tests/ui/inputnumberview.js b/packages/ckeditor5-list/tests/ui/inputnumberview.js new file mode 100644 index 00000000000..3891dd0e5fb --- /dev/null +++ b/packages/ckeditor5-list/tests/ui/inputnumberview.js @@ -0,0 +1,147 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import InputNumberView, { createLabeledInputNumber } from '../../src/ui/inputnumberview'; + +import { LabeledFieldView, InputView } from '@ckeditor/ckeditor5-ui'; + +describe( 'InputNumberView', () => { + let view; + + beforeEach( () => { + view = new InputNumberView(); + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should extend InputView', () => { + expect( view ).to.be.instanceOf( InputView ); + } ); + + it( 'should create element from template', () => { + expect( view.element.getAttribute( 'type' ) ).to.equal( 'number' ); + expect( view.element.type ).to.equal( 'number' ); + expect( view.element.classList.contains( 'ck-input-number' ) ).to.be.true; + + expect( view.element.getAttribute( 'min' ) ).to.be.null; + expect( view.element.getAttribute( 'max' ) ).to.be.null; + expect( view.element.getAttribute( 'step' ) ).to.be.null; + } ); + } ); + + describe( 'DOM bindings', () => { + describe( 'min attribute', () => { + it( 'should respond to view#min', () => { + expect( view.element.getAttribute( 'min' ) ).to.be.null; + + view.min = 20; + + expect( view.element.getAttribute( 'min' ) ).to.equal( '20' ); + } ); + } ); + + describe( 'max attribute', () => { + it( 'should respond to view#max', () => { + expect( view.element.getAttribute( 'max' ) ).to.be.null; + + view.max = 20; + + expect( view.element.getAttribute( 'max' ) ).to.equal( '20' ); + } ); + } ); + + describe( 'step attribute', () => { + it( 'should respond to view#step', () => { + expect( view.element.getAttribute( 'step' ) ).to.be.null; + + view.step = 20; + + expect( view.element.getAttribute( 'step' ) ).to.equal( '20' ); + } ); + } ); + } ); +} ); + +describe( 'createLabeledInputNumber()', () => { + let labeledInput, locale; + + beforeEach( () => { + locale = { t: val => val }; + labeledInput = new LabeledFieldView( locale, createLabeledInputNumber ); + } ); + + afterEach( () => { + labeledInput.destroy(); + } ); + + it( 'should create an InputNumberView instance', () => { + expect( labeledInput.fieldView ).to.be.instanceOf( InputNumberView ); + } ); + + it( 'should pass the Locale to the input', () => { + expect( labeledInput.fieldView.locale ).to.equal( locale ); + } ); + + it( 'should set input #id and #ariaDescribedById', () => { + labeledInput.render(); + + expect( labeledInput.fieldView.id ).to.equal( labeledInput.labelView.for ); + expect( labeledInput.fieldView.ariaDescribedById ).to.equal( labeledInput.statusView.element.id ); + } ); + + it( 'should set #inputMode to "numeric"', () => { + expect( labeledInput.fieldView.inputMode ).to.equal( 'numeric' ); + } ); + + it( 'should bind input\'s #isReadOnly to labeledInput#isEnabled', () => { + labeledInput.isEnabled = true; + expect( labeledInput.fieldView.isReadOnly ).to.be.false; + + labeledInput.isEnabled = false; + expect( labeledInput.fieldView.isReadOnly ).to.be.true; + } ); + + it( 'should bind input\'s #hasError to labeledInput#errorText', () => { + labeledInput.errorText = 'some error'; + expect( labeledInput.fieldView.hasError ).to.be.true; + + labeledInput.errorText = null; + expect( labeledInput.fieldView.hasError ).to.be.false; + } ); + + it( 'should bind labeledInput#isEmpty to input\'s #isEmpty', () => { + labeledInput.fieldView.isEmpty = true; + expect( labeledInput.isEmpty ).to.be.true; + + labeledInput.fieldView.isEmpty = false; + expect( labeledInput.isEmpty ).to.be.false; + } ); + + it( 'should bind labeledInput#isFocused to input\'s #isFocused', () => { + labeledInput.fieldView.isFocused = true; + expect( labeledInput.isFocused ).to.be.true; + + labeledInput.fieldView.isFocused = false; + expect( labeledInput.isFocused ).to.be.false; + } ); + + it( 'should bind labeledInput#placeholder to input\'s #placeholder', () => { + labeledInput.fieldView.placeholder = null; + expect( labeledInput.placeholder ).to.be.null; + + labeledInput.fieldView.placeholder = 'foo'; + expect( labeledInput.placeholder ).to.equal( 'foo' ); + } ); + + it( 'should clean labeledInput#errorText upon input\'s DOM "update" event', () => { + labeledInput.errorText = 'some error'; + labeledInput.fieldView.fire( 'input' ); + expect( labeledInput.errorText ).to.be.null; + } ); +} ); diff --git a/packages/ckeditor5-list/tests/ui/listpropertiesview.js b/packages/ckeditor5-list/tests/ui/listpropertiesview.js new file mode 100644 index 00000000000..444234c3a50 --- /dev/null +++ b/packages/ckeditor5-list/tests/ui/listpropertiesview.js @@ -0,0 +1,648 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document, Event */ + +import ListPropertiesView from '../../src/ui/listpropertiesview'; +import CollapsibleView from '../../src/ui/collapsibleview'; + +import { + ButtonView, + FocusCycler, + LabeledFieldView, + SwitchButtonView, + View, + ViewCollection +} from '@ckeditor/ckeditor5-ui'; + +import { + FocusTracker, + KeystrokeHandler, + keyCodes +} from '@ckeditor/ckeditor5-utils'; + +describe( 'ListPropertiesView', () => { + let view, locale; + + beforeEach( () => { + locale = { t: text => text }; + view = new ListPropertiesView( locale, { + enabledProperties: { + styles: true, + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + describe( 'constructor()', () => { + describe( 'template', () => { + it( 'should create an #element from the template', () => { + expect( view.element.tagName ).to.equal( 'DIV' ); + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-list-properties' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-list-properties_with-numbered-properties' ) ).to.be.true; + } ); + + describe( 'when styles, start index, and reversed properties are enabled', () => { + it( 'should use collapsible to host property fields', () => { + expect( view.children.first ).to.equal( view.stylesView ); + expect( view.children.last ).to.be.instanceOf( CollapsibleView ); + expect( view.children.last.label ).to.equal( 'List properties' ); + expect( view.children.last.isCollapsed ).to.be.true; + expect( view.children.last.children.first ).to.equal( view.startIndexFieldView ); + expect( view.children.last.children.last ).to.equal( view.reversedSwitchButtonView ); + } ); + + it( 'should keep the collapsible button enabled as longs as either start index or reversed field is enabled', () => { + const collapsibleView = view.children.last; + + expect( collapsibleView.buttonView.isEnabled, 'A' ).to.be.true; + + view.startIndexFieldView.isEnabled = false; + view.reversedSwitchButtonView.isEnabled = true; + expect( collapsibleView.buttonView.isEnabled, 'B' ).to.be.true; + + view.startIndexFieldView.isEnabled = true; + view.reversedSwitchButtonView.isEnabled = false; + expect( collapsibleView.buttonView.isEnabled, 'C' ).to.be.true; + + view.startIndexFieldView.isEnabled = true; + view.reversedSwitchButtonView.isEnabled = true; + expect( collapsibleView.buttonView.isEnabled, 'D' ).to.be.true; + + view.startIndexFieldView.isEnabled = false; + view.reversedSwitchButtonView.isEnabled = false; + expect( collapsibleView.buttonView.isEnabled, 'E' ).to.be.false; + } ); + + it( 'should automatically collapse the collapsible when its button gets gets disabled', () => { + const collapsibleView = view.children.last; + + collapsibleView.isCollapsed = false; + + view.startIndexFieldView.isEnabled = false; + view.reversedSwitchButtonView.isEnabled = true; + expect( collapsibleView.isCollapsed, 'A' ).to.be.false; + + view.startIndexFieldView.isEnabled = true; + view.reversedSwitchButtonView.isEnabled = false; + expect( collapsibleView.isCollapsed, 'B' ).to.be.false; + + view.startIndexFieldView.isEnabled = false; + view.reversedSwitchButtonView.isEnabled = false; + expect( collapsibleView.isCollapsed, 'C' ).to.be.true; + + // It should work only one way. It should not uncollapse when property fields get enabled. + view.startIndexFieldView.isEnabled = true; + view.reversedSwitchButtonView.isEnabled = true; + expect( collapsibleView.isCollapsed, 'D' ).to.be.true; + } ); + } ); + + describe( 'when styles are disabled but start index and reversed properties are enabled', () => { + it( 'should have no #stylesView and get a specific CSS class', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.stylesView ).to.be.null; + expect( view.element.classList.contains( 'ck-list-properties_without-styles' ) ).to.be.true; + + view.destroy(); + } ); + + it( 'should not use CollapsibleView for #startIndexFieldView and #reversedSwitchButtonView', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.children.first ).to.equal( view.startIndexFieldView ); + expect( view.children.last ).to.equal( view.reversedSwitchButtonView ); + + view.destroy(); + } ); + } ); + + describe( 'when only styles property is enabled', () => { + it( 'should not have no #startIndexFieldView, no #reversedSwitchButtonView, and no specific CSS class', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + styles: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.startIndexFieldView ).to.be.null; + expect( view.reversedSwitchButtonView ).to.be.null; + expect( view.children.first ).to.equal( view.stylesView ); + expect( view.children.last ).to.equal( view.stylesView ); + expect( view.element.classList.contains( 'ck-list-properties_with-numbered-properties' ) ).to.be.false; + + view.destroy(); + } ); + } ); + + describe( 'when only start index property is enabled', () => { + it( 'should not have no #stylesView, no #reversedSwitchButtonView', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.stylesView ).to.be.null; + expect( view.startIndexFieldView ).to.be.instanceOf( LabeledFieldView ); + expect( view.reversedSwitchButtonView ).to.be.null; + expect( view.children.first ).to.equal( view.startIndexFieldView ); + expect( view.children.last ).to.equal( view.startIndexFieldView ); + expect( view.element.classList.contains( 'ck-list-properties_with-numbered-properties' ) ).to.be.true; + + view.destroy(); + } ); + } ); + + describe( 'when only reversed property is enabled', () => { + it( 'should not have no #stylesView, no #startIndexFieldView', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.stylesView ).to.be.null; + expect( view.startIndexFieldView ).to.be.null; + expect( view.reversedSwitchButtonView ).to.be.instanceOf( SwitchButtonView ); + expect( view.children.first ).to.equal( view.reversedSwitchButtonView ); + expect( view.children.last ).to.equal( view.reversedSwitchButtonView ); + expect( view.element.classList.contains( 'ck-list-properties_with-numbered-properties' ) ).to.be.true; + + view.destroy(); + } ); + } ); + } ); + + it( 'should have a #children collection', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #stylesView', () => { + expect( view.stylesView ).to.be.instanceOf( View ); + } ); + + it( 'should have #startIndexFieldView', () => { + expect( view.startIndexFieldView ).to.be.instanceOf( LabeledFieldView ); + } ); + + it( 'should have #reversedSwitchButtonView', () => { + expect( view.reversedSwitchButtonView ).to.be.instanceOf( SwitchButtonView ); + } ); + + it( 'should have #focusTracker', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should have #keystrokes', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should have #focusables', () => { + expect( view.focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #focusCycler', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + describe( '#stylesView', () => { + describe( 'template', () => { + it( 'should create an element from the template', () => { + expect( view.stylesView.element.tagName ).to.equal( 'DIV' ); + expect( view.stylesView.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.stylesView.element.classList.contains( 'ck-list-styles-list' ) ).to.be.true; + expect( view.stylesView.element.getAttribute( 'aria-label' ) ).to.equal( 'Foo' ); + } ); + + it( 'should popupate the view with style buttons', () => { + expect( view.stylesView.children.length ).to.equal( 2 ); + expect( view.stylesView.children.get( 0 ) ).to.be.instanceOf( ButtonView ); + expect( view.stylesView.children.get( 1 ) ).to.be.instanceOf( ButtonView ); + expect( view.stylesView.element.firstChild.classList.contains( 'ck-button' ) ).to.be.true; + expect( view.stylesView.element.lastChild.classList.contains( 'ck-button' ) ).to.be.true; + } ); + } ); + } ); + + describe( '#startIndexFieldView', () => { + it( 'should have basic properties', () => { + expect( view.startIndexFieldView.label ).to.equal( 'Start at' ); + expect( view.startIndexFieldView.class ).to.equal( 'ck-numbered-list-properties__start-index' ); + expect( view.startIndexFieldView.fieldView.min ).to.equal( 1 ); + expect( view.startIndexFieldView.fieldView.step ).to.equal( 1 ); + expect( view.startIndexFieldView.fieldView.value ).to.equal( 1 ); + expect( view.startIndexFieldView.fieldView.inputMode ).to.equal( 'numeric' ); + } ); + } ); + + describe( '#reversedSwitchButtonView', () => { + it( 'should have basic properties', () => { + expect( view.reversedSwitchButtonView.withText ).to.be.true; + expect( view.reversedSwitchButtonView.label ).to.equal( 'Reversed order' ); + expect( view.reversedSwitchButtonView.class ).to.equal( 'ck-numbered-list-properties__reversed-order' ); + } ); + } ); + } ); + + describe( 'render()', () => { + describe( 'focus cycling, tracking and keyboard support', () => { + describe( 'when styles and all numbered list properties are enabled', () => { + it( 'should register child views in #focusables', () => { + expect( view.focusables.map( f => f ) ).to.have.members( [ + view.stylesView.children.first, + view.stylesView.children.last, + view.children.last.buttonView, + view.startIndexFieldView, + view.reversedSwitchButtonView + ] ); + } ); + + it( 'should register child views\' #element in #focusTracker', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + styles: true, + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + const spy = sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.stylesView.children.first.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.stylesView.children.last.element ); + sinon.assert.calledWithExactly( spy.getCall( 2 ), view.children.last.buttonView.element ); + sinon.assert.calledWithExactly( spy.getCall( 3 ), view.startIndexFieldView.element ); + sinon.assert.calledWithExactly( spy.getCall( 4 ), view.reversedSwitchButtonView.element ); + + view.destroy(); + } ); + } ); + + describe( 'when styles grid is disabled', () => { + it( 'should register child views in #focusables', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + + expect( view.focusables.map( f => f ) ).to.have.members( [ + view.startIndexFieldView, + view.reversedSwitchButtonView + ] ); + + view.destroy(); + } ); + + it( 'should register child views\' #element in #focusTracker', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + const spy = sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.startIndexFieldView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.reversedSwitchButtonView.element ); + + view.destroy(); + } ); + } ); + + it( 'starts listening for #keystrokes coming from #element', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + styles: true, + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ), + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + const spy = sinon.spy( view.keystrokes, 'listenTo' ); + + view.render(); + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation in the properties view', () => { + it( 'so "tab" focuses the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first style button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.stylesView.children.first.element; + + const spy = sinon.spy( view.stylesView.children.last, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the first style button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.stylesView.children.first.element; + view.children.last.isCollapsed = false; + + const spy = sinon.spy( view.reversedSwitchButtonView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + + it( 'intercepts the arrow* events and overrides the default (parent) toolbar behavior', () => { + const keyEvtData = { + stopPropagation: sinon.spy() + }; + + keyEvtData.keyCode = keyCodes.arrowdown; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowup; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledTwice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowleft; + view.keystrokes.press( keyEvtData ); + sinon.assert.calledThrice( keyEvtData.stopPropagation ); + + keyEvtData.keyCode = keyCodes.arrowright; + view.keystrokes.press( keyEvtData ); + sinon.assert.callCount( keyEvtData.stopPropagation, 4 ); + } ); + + it( 'intercepts the "selectstart" in the #startIndexFieldView with the high priority to unlock select all', () => { + const spy = sinon.spy(); + const event = new Event( 'selectstart', { + bubbles: true, + cancelable: true + } ); + + event.stopPropagation = spy; + + view.startIndexFieldView.element.dispatchEvent( event ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus the first button in #stylesView (when present)', () => { + const spy = sinon.spy( view.stylesView.children.first, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should focus the #startIndexFieldView when there are no style buttons', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + startIndex: true, + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + document.body.appendChild( view.element ); + + const spy = sinon.spy( view.startIndexFieldView, 'focus' ); + + view.focus(); + sinon.assert.calledOnce( spy ); + + view.element.remove(); + view.destroy(); + } ); + + it( 'should focus the #reversedSwitchButtonView if no #stylesView and no #startIndexFieldView', () => { + const view = new ListPropertiesView( locale, { + enabledProperties: { + reversed: true + }, + styleButtonViews: [ + new ButtonView( locale ) + ], + styleGridAriaLabel: 'Foo' + } ); + + view.render(); + document.body.appendChild( view.element ); + + const spy = sinon.spy( view.reversedSwitchButtonView, 'focus' ); + + view.focus(); + sinon.assert.calledOnce( spy ); + + view.element.remove(); + view.destroy(); + } ); + } ); + + describe( 'focusLast()', () => { + it( 'should focus the #reversedSwitchButtonView when present and visible', () => { + const spy = sinon.spy( view.reversedSwitchButtonView, 'focus' ); + + view.children.last.isCollapsed = false; + view.focusLast(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should focus the collapse button when numbered list properies are collapsed', () => { + const spy = sinon.spy( view.children.last.buttonView, 'focus' ); + + view.children.last.isCollapsed = true; + view.focusLast(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'DOM bindings', () => { + describe( 'styles view', () => { + it( 'should delegate #execute to the properties view', () => { + const spy = sinon.spy(); + + view.on( 'execute', spy ); + view.stylesView.children.get( 0 ).fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( '#startIndexFieldView', () => { + it( 'should fire #listStart upon #input', () => { + const spy = sinon.spy(); + view.on( 'listStart', spy ); + + view.startIndexFieldView.fieldView.value = '123'; + view.startIndexFieldView.fieldView.fire( 'input' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should not fire #listStart upon #input if the field is empty', () => { + const spy = sinon.spy(); + view.on( 'listStart', spy ); + + view.startIndexFieldView.fieldView.value = ''; + view.startIndexFieldView.fieldView.fire( 'input' ); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should not fire #listStart upon #input but display an errir if the field is invalid', () => { + const spy = sinon.spy(); + view.on( 'listStart', spy ); + + view.startIndexFieldView.fieldView.value = '-5'; + view.startIndexFieldView.fieldView.fire( 'input' ); + + sinon.assert.notCalled( spy ); + expect( view.startIndexFieldView.errorText ).to.equal( 'Start index must be greater than 0.' ); + } ); + } ); + + describe( '#reversedSwitchButtonView', () => { + it( 'should fire #listReversed when executed', () => { + const spy = sinon.spy(); + view.on( 'listReversed', spy ); + + view.reversedSwitchButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-list/theme/collapsible.css b/packages/ckeditor5-list/theme/collapsible.css new file mode 100644 index 00000000000..a0d19027132 --- /dev/null +++ b/packages/ckeditor5-list/theme/collapsible.css @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck.ck-collapsible.ck-collapsible_collapsed { + & > .ck-collapsible__children { + display: none; + } +} diff --git a/packages/ckeditor5-ui/theme/components/inputtext/inputtext.css b/packages/ckeditor5-list/theme/listproperties.css similarity index 100% rename from packages/ckeditor5-ui/theme/components/inputtext/inputtext.css rename to packages/ckeditor5-list/theme/listproperties.css diff --git a/packages/ckeditor5-list/theme/liststyles.css b/packages/ckeditor5-list/theme/liststyles.css index b5d7c8d6f34..ec549da5fe8 100644 --- a/packages/ckeditor5-list/theme/liststyles.css +++ b/packages/ckeditor5-list/theme/liststyles.css @@ -3,10 +3,6 @@ * 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 a panel with thumbnails (previews). - */ +.ck.ck-list-styles-list { display: grid; } diff --git a/packages/ckeditor5-media-embed/tests/ui/mediaformview.js b/packages/ckeditor5-media-embed/tests/ui/mediaformview.js index 70e0ab9868d..809b7fd761c 100644 --- a/packages/ckeditor5-media-embed/tests/ui/mediaformview.js +++ b/packages/ckeditor5-media-embed/tests/ui/mediaformview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Event */ +/* globals document, Event */ import MediaFormView from '../../src/ui/mediaformview'; import View from '@ckeditor/ckeditor5-ui/src/view'; @@ -22,6 +22,12 @@ describe( 'MediaFormView', () => { beforeEach( () => { view = new MediaFormView( [], { t: val => val } ); view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); } ); describe( 'constructor()', () => { @@ -122,7 +128,7 @@ describe( 'MediaFormView', () => { } ); it( 'should register child views\' #element in #focusTracker', () => { - view = new MediaFormView( [], { t: () => {} } ); + const view = new MediaFormView( [], { t: () => {} } ); const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); @@ -131,16 +137,20 @@ describe( 'MediaFormView', () => { sinon.assert.calledWithExactly( spy.getCall( 0 ), view.urlInputView.element ); sinon.assert.calledWithExactly( spy.getCall( 1 ), view.saveButtonView.element ); sinon.assert.calledWithExactly( spy.getCall( 2 ), view.cancelButtonView.element ); + + view.destroy(); } ); it( 'starts listening for #keystrokes coming from #element', () => { - view = new MediaFormView( [], { t: () => {} } ); + const view = new MediaFormView( [], { t: () => {} } ); const spy = sinon.spy( view.keystrokes, 'listenTo' ); view.render(); sinon.assert.calledOnce( spy ); sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); } ); describe( 'activates keyboard navigation for the toolbar', () => { diff --git a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js index 56a69d75bdd..78fac94d672 100644 --- a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js +++ b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Event */ +/* globals document, Event */ import TableCellPropertiesView from '../../../src/tablecellproperties/ui/tablecellpropertiesview'; import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; @@ -61,9 +61,11 @@ describe( 'table cell properties', () => { locale = { t: val => val }; view = new TableCellPropertiesView( locale, VIEW_OPTIONS ); view.render(); + document.body.appendChild( view.element ); } ); afterEach( () => { + view.element.remove(); view.destroy(); } ); diff --git a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js index 2510ee92c0f..213f42e57f2 100644 --- a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js +++ b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals Event */ +/* globals document, Event */ import TablePropertiesView from '../../../src/tableproperties/ui/tablepropertiesview'; import LabeledFieldView from '@ckeditor/ckeditor5-ui/src/labeledfield/labeledfieldview'; @@ -59,9 +59,11 @@ describe( 'table properties', () => { locale = { t: val => val }; view = new TablePropertiesView( locale, VIEW_OPTIONS ); view.render(); + document.body.appendChild( view.element ); } ); afterEach( () => { + view.element.remove(); view.destroy(); } ); diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css index e4d74eb2658..41c0ee6e305 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkactions.css @@ -23,7 +23,7 @@ /* Match the box model of the link editor form's input so the balloon does not change width when moving between actions and the form. */ - max-width: var(--ck-input-text-width); + max-width: var(--ck-input-width); min-width: 3em; text-align: center; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css index 492802d5127..2c085148c7d 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-link/linkform.css @@ -11,7 +11,7 @@ */ .ck.ck-link-form_layout-vertical { padding: 0; - min-width: var(--ck-input-text-width); + min-width: var(--ck-input-width); & .ck-labeled-field-view { margin: var(--ck-spacing-large) var(--ck-spacing-large) var(--ck-spacing-small); diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css new file mode 100644 index 00000000000..6c6da25daa2 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/collapsible.css @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +:root { + --ck-collapsible-arrow-size: calc(0.5 * var(--ck-icon-size)); +} + +.ck.ck-collapsible { + & > .ck.ck-button { + width: 100%; + font-weight: bold; + padding: var(--ck-spacing-medium) var(--ck-spacing-large); + border-radius: 0; + + &:focus { + background: transparent; + } + + &:active, &:not(:focus), &:hover:not(:focus) { + background: transparent; + border-color: transparent; + box-shadow: none; + } + + & > .ck-icon { + margin-right: var(--ck-spacing-medium); + width: var(--ck-collapsible-arrow-size); + } + } + + & > .ck-collapsible__children { + padding: 0 var(--ck-spacing-large) var(--ck-spacing-large); + } + + &.ck-collapsible_collapsed { + & > .ck.ck-button .ck-icon { + transform: rotate(-90deg); + } + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/listproperties.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/listproperties.css new file mode 100644 index 00000000000..e8bff8be263 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/listproperties.css @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck.ck-list-properties { + /* When there are no list styles and there is no collapsible. */ + &.ck-list-properties_without-styles { + padding: var(--ck-spacing-large); + + & > * { + min-width: 14em; + + & + * { + margin-top: var(--ck-spacing-standard); + } + } + } + + /* + * When the numbered list property fields (start at, reversed) should be displayed, + * more horizontal space is needed. Reconfigure the style grid to create that space. + */ + &.ck-list-properties_with-numbered-properties { + & > .ck-list-styles-list { + grid-template-columns: repeat( 4, auto ); + } + + /* When list styles are rendered and property fields are in a collapsible. */ + & > .ck-collapsible { + border-top: 1px solid var(--ck-color-base-border); + + & > .ck-collapsible__children { + & > * { + width: 100%; + + & + * { + margin-top: var(--ck-spacing-standard); + } + } + } + } + } + + & .ck.ck-numbered-list-properties__start-index .ck-input { + min-width: auto; + width: 100%; + } + + & .ck.ck-numbered-list-properties__reversed-order { + background: transparent; + padding-left: 0; + padding-right: 0; + margin-bottom: calc(-1 * var(--ck-spacing-tiny)); + + &:active, &:hover { + box-shadow: none; + border-color: transparent; + background: none; + } + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css index 0ea408f587c..94d93038f1b 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-list/liststyles.css @@ -7,39 +7,34 @@ --ck-list-style-button-size: 44px; } -.ck.ck-list-styles-dropdown > .ck-dropdown__panel > .ck-toolbar { - background: none; - padding: 0; +.ck.ck-list-styles-list { + grid-template-columns: repeat( 3, auto ); + row-gap: var(--ck-spacing-medium); + column-gap: var(--ck-spacing-medium); + padding: var(--ck-spacing-large); - & > .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; - & .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; + /* + * 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; + /* + * 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); - } + & .ck-icon { + width: var(--ck-list-style-button-size); + height: var(--ck-list-style-button-size); } } } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/inputtext/inputtext.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/input/input.css similarity index 86% rename from packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/inputtext/inputtext.css rename to packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/input/input.css index f575ad357bc..4342160a0ed 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/inputtext/inputtext.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/input/input.css @@ -8,16 +8,19 @@ @import "../../../mixins/_shadow.css"; :root { - --ck-input-text-width: 18em; + --ck-input-width: 18em; + + /* Backward compatibility. */ + --ck-input-text-width: var(--ck-input-width); } -.ck.ck-input-text { +.ck.ck-input { @mixin ck-rounded-corners; background: var(--ck-color-input-background); border: 1px solid var(--ck-color-input-border); padding: var(--ck-spacing-extra-tiny) var(--ck-spacing-medium); - min-width: var(--ck-input-text-width); + min-width: var(--ck-input-width); /* This is important to stay of the same height as surrounding buttons */ min-height: var(--ck-ui-component-min-height); @@ -43,7 +46,7 @@ &.ck-error { border-color: var(--ck-color-input-error-border); - animation: ck-text-input-shake .3s ease both; + animation: ck-input-shake .3s ease both; &:focus { @mixin ck-box-shadow var(--ck-focus-error-outer-shadow); @@ -51,7 +54,7 @@ } } -@keyframes ck-text-input-shake { +@keyframes ck-input-shake { 20% { transform: translateX(-2px); } diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css index 52067cbf9fd..641a42719ba 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/responsive-form/responsiveform.css @@ -32,7 +32,7 @@ @mixin ck-media-phone { padding: 0; - width: calc(.8 * var(--ck-input-text-width)); + width: calc(.8 * var(--ck-input-width)); & .ck-labeled-field-view { margin: var(--ck-spacing-large) var(--ck-spacing-large) 0; diff --git a/packages/ckeditor5-ui/src/focuscycler.js b/packages/ckeditor5-ui/src/focuscycler.js index b826b9281f6..6b45154ac30 100644 --- a/packages/ckeditor5-ui/src/focuscycler.js +++ b/packages/ckeditor5-ui/src/focuscycler.js @@ -7,7 +7,7 @@ * @module ui/focuscycler */ -import global from '@ckeditor/ckeditor5-utils/src/dom/global'; +import isVisible from '@ckeditor/ckeditor5-utils/src/dom/isvisible'; /** * A utility class that helps cycling over focusable {@link module:ui/view~View views} in a @@ -131,6 +131,8 @@ export default class FocusCycler { * Returns the first focusable view in {@link #focusables}. * Returns `null` if there is none. * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. + * * @readonly * @member {module:ui/view~View|null} #first */ @@ -142,6 +144,8 @@ export default class FocusCycler { * Returns the last focusable view in {@link #focusables}. * Returns `null` if there is none. * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. + * * @readonly * @member {module:ui/view~View|null} #last */ @@ -153,6 +157,8 @@ export default class FocusCycler { * Returns the next focusable view in {@link #focusables} based on {@link #current}. * Returns `null` if there is none. * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. + * * @readonly * @member {module:ui/view~View|null} #next */ @@ -164,6 +170,8 @@ export default class FocusCycler { * Returns the previous focusable view in {@link #focusables} based on {@link #current}. * Returns `null` if there is none. * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. + * * @readonly * @member {module:ui/view~View|null} #previous */ @@ -201,6 +209,8 @@ export default class FocusCycler { /** * Focuses the {@link #first} item in {@link #focusables}. + * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ focusFirst() { this._focus( this.first ); @@ -208,6 +218,8 @@ export default class FocusCycler { /** * Focuses the {@link #last} item in {@link #focusables}. + * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ focusLast() { this._focus( this.last ); @@ -215,6 +227,8 @@ export default class FocusCycler { /** * Focuses the {@link #next} item in {@link #focusables}. + * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ focusNext() { this._focus( this.next ); @@ -222,6 +236,8 @@ export default class FocusCycler { /** * Focuses the {@link #previous} item in {@link #focusables}. + * + * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ focusPrevious() { this._focus( this.previous ); @@ -269,7 +285,6 @@ export default class FocusCycler { do { const view = this.focusables.get( index ); - // TODO: Check if view is visible. if ( isFocusable( view ) ) { return view; } @@ -288,5 +303,5 @@ export default class FocusCycler { // @param {module:ui/view~View} view A view to be checked. // @returns {Boolean} function isFocusable( view ) { - return !!( view.focus && global.window.getComputedStyle( view.element ).display != 'none' ); + return !!( view.focus && isVisible( view.element ) ); } diff --git a/packages/ckeditor5-ui/src/index.js b/packages/ckeditor5-ui/src/index.js index ea037343924..de7e63cf5c1 100644 --- a/packages/ckeditor5-ui/src/index.js +++ b/packages/ckeditor5-ui/src/index.js @@ -32,6 +32,7 @@ export { default as FormHeaderView } from './formheader/formheaderview'; export { default as FocusCycler } from './focuscycler'; export { default as IconView } from './icon/iconview'; +export { default as InputView } from './input/inputview'; export { default as InputTextView } from './inputtext/inputtextview'; export { default as IframeView } from './iframe/iframeview'; diff --git a/packages/ckeditor5-ui/src/input/inputview.js b/packages/ckeditor5-ui/src/input/inputview.js new file mode 100644 index 00000000000..278b96851fe --- /dev/null +++ b/packages/ckeditor5-ui/src/input/inputview.js @@ -0,0 +1,216 @@ +/** + * @license Copyright (c) 2003-2021, CKSource - Frederico Knabben. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/input/inputview + */ + +import View from '../view'; +import FocusTracker from '@ckeditor/ckeditor5-utils/src/focustracker'; + +import '../../theme/components/input/input.css'; + +/** + * The base input view class. + * + * @extends module:ui/view~View + */ +export default class InputView extends View { + /** + * @inheritDoc + */ + constructor( locale ) { + super( locale ); + + /** + * The value of the input. + * + * @observable + * @member {String} #value + */ + this.set( 'value' ); + + /** + * The `id` attribute of the input (i.e. to pair with a `