diff --git a/docs/features/index.md b/docs/features/index.md index bcd78f17f5f..5f07839cc6d 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -59,7 +59,7 @@ Additionally, CKEditor 5 offers the {@link features/restricted-editing rest {@img assets/img/features-collaboration.png 800 CKEditor 5 collaboration features.} -You can also easily track the progress and changes done in the content with the {@link features/revision-history revision history feature} {@icon @ckeditor/ckeditor5-revision-history/theme/icons/revision-history.svg Revision history}. This modern and robust document versioning tool lets you create named versions, compare changes, and restore previous document versions at ease, tracking all progress — also when multiple editors work together. +You can also easily track the progress and changes done in the content with the {@link features/revision-history revision history feature} {@icon @ckeditor/ckeditor5-core/theme/icons/history.svg Revision history}. This modern and robust document versioning tool lets you create named versions, compare changes, and restore previous document versions at ease, tracking all progress — also when multiple editors work together. {@img assets/img/features-revision-history.png 800 CKEditor 5 document versioning feature.} diff --git a/packages/ckeditor5-core/src/index.ts b/packages/ckeditor5-core/src/index.ts index 9cbe6bd1699..8bd7878460a 100644 --- a/packages/ckeditor5-core/src/index.ts +++ b/packages/ckeditor5-core/src/index.ts @@ -38,7 +38,9 @@ import caption from './../theme/icons/caption.svg'; import check from './../theme/icons/check.svg'; import cog from './../theme/icons/cog.svg'; import eraser from './../theme/icons/eraser.svg'; +import history from './../theme/icons/history.svg'; import lowVision from './../theme/icons/low-vision.svg'; +import loupe from './../theme/icons/loupe.svg'; import image from './../theme/icons/image.svg'; import alignBottom from './../theme/icons/align-bottom.svg'; @@ -81,8 +83,10 @@ export const icons = { check, cog, eraser, + history, image, lowVision, + loupe, importExport, paragraph, plus, diff --git a/packages/ckeditor5-core/theme/icons/history.svg b/packages/ckeditor5-core/theme/icons/history.svg new file mode 100644 index 00000000000..daf9197f3e6 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/history.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-core/theme/icons/loupe.svg b/packages/ckeditor5-core/theme/icons/loupe.svg new file mode 100644 index 00000000000..ba9b3fa8962 --- /dev/null +++ b/packages/ckeditor5-core/theme/icons/loupe.svg @@ -0,0 +1 @@ + diff --git a/packages/ckeditor5-mention/src/ui/domwrapperview.ts b/packages/ckeditor5-mention/src/ui/domwrapperview.ts index f6bcd392792..3828944b414 100644 --- a/packages/ckeditor5-mention/src/ui/domwrapperview.ts +++ b/packages/ckeditor5-mention/src/ui/domwrapperview.ts @@ -72,4 +72,11 @@ export default class DomWrapperView extends View { this.element = this.domElement; } + + /** + * Focuses the DOM element. + */ + public focus(): void { + this.domElement.focus(); + } } diff --git a/packages/ckeditor5-mention/tests/ui/domwrapperview.js b/packages/ckeditor5-mention/tests/ui/domwrapperview.js new file mode 100644 index 00000000000..0496d5d0314 --- /dev/null +++ b/packages/ckeditor5-mention/tests/ui/domwrapperview.js @@ -0,0 +1,70 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document, Event */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import DomWrapperView from '../../src/ui/domwrapperview'; + +describe( 'DomWrapperView', () => { + let domElement, view; + + beforeEach( () => { + domElement = document.createElement( 'div' ); + view = new DomWrapperView( new Locale(), domElement ); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should add CSS class to the element', () => { + expect( domElement.classList.contains( 'ck-button' ) ).to.be.true; + } ); + + it( 'should set #isOn observable property with a CSS class binding', () => { + expect( view.isOn ).to.be.false; + + // TODO: This is actually a bug because the initial state is not set correctly. + expect( domElement.classList.contains( 'ck-on' ) ).to.be.false; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.false; + + view.isOn = true; + expect( domElement.classList.contains( 'ck-on' ) ).to.be.true; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.false; + + view.isOn = false; + expect( domElement.classList.contains( 'ck-on' ) ).to.be.false; + expect( domElement.classList.contains( 'ck-off' ) ).to.be.true; + } ); + + it( 'should fire #execute on DOM element click', () => { + const spy = sinon.spy(); + view.on( 'execute', spy ); + + domElement.dispatchEvent( new Event( 'click' ) ); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'render()', () => { + it( 'should assign passed element to #element', () => { + view.render(); + expect( view.element ).to.equal( domElement ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the #domElement', () => { + const spy = sinon.spy( domElement, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts index a79229a7787..19bebde3410 100644 --- a/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts +++ b/packages/ckeditor5-table/src/tablecellproperties/ui/tablecellpropertiesview.ts @@ -22,7 +22,9 @@ import { ViewCollection, type FocusableView, type NormalizedColorOption, - type ColorPickerConfig + type ColorPickerConfig, + type FocusCyclerBackwardCycleEvent, + type FocusCyclerForwardCycleEvent } from 'ckeditor5/src/ui'; import { KeystrokeHandler, @@ -390,13 +392,24 @@ export default class TableCellPropertiesView extends View { view: this } ); + // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. + [ this.borderColorInput, this.backgroundInput ].forEach( view => { + view.fieldView.focusCycler.on( 'forwardCycle', evt => { + this._focusCycler.focusNext(); + evt.stop(); + } ); + + view.fieldView.focusCycler.on( 'backwardCycle', evt => { + this._focusCycler.focusPrevious(); + evt.stop(); + } ); + } ); + [ this.borderStyleDropdown, this.borderColorInput, - this.borderColorInput.fieldView.dropdownView.buttonView, this.borderWidthInput, this.backgroundInput, - this.backgroundInput.fieldView.dropdownView.buttonView, this.widthInput, this.heightInput, this.paddingInput, diff --git a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts index af42ddff5bb..f0ef3c04092 100644 --- a/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts +++ b/packages/ckeditor5-table/src/tableproperties/ui/tablepropertiesview.ts @@ -23,7 +23,9 @@ import { type DropdownView, type InputTextView, type NormalizedColorOption, - type ColorPickerConfig + type ColorPickerConfig, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler, type ObservableChangeEvent, type Locale } from 'ckeditor5/src/utils'; import { icons } from 'ckeditor5/src/core'; @@ -358,13 +360,24 @@ export default class TablePropertiesView extends View { view: this } ); + // Maintain continuous focus cycling over views that have focusable children and focus cyclers themselves. + [ this.borderColorInput, this.backgroundInput ].forEach( view => { + view.fieldView.focusCycler.on( 'forwardCycle', evt => { + this._focusCycler.focusNext(); + evt.stop(); + } ); + + view.fieldView.focusCycler.on( 'backwardCycle', evt => { + this._focusCycler.focusPrevious(); + evt.stop(); + } ); + } ); + [ this.borderStyleDropdown, this.borderColorInput, - this.borderColorInput!.fieldView.dropdownView.buttonView, this.borderWidthInput, this.backgroundInput, - this.backgroundInput!.fieldView.dropdownView.buttonView, this.widthInput, this.heightInput, this.alignmentToolbar, diff --git a/packages/ckeditor5-table/src/ui/colorinputview.ts b/packages/ckeditor5-table/src/ui/colorinputview.ts index f7dc0890836..5623065fa56 100644 --- a/packages/ckeditor5-table/src/ui/colorinputview.ts +++ b/packages/ckeditor5-table/src/ui/colorinputview.ts @@ -18,7 +18,8 @@ import { type DropdownView, type ColorPickerConfig, type ColorSelectorExecuteEvent, - type ColorSelectorColorPickerCancelEvent + type ColorSelectorColorPickerCancelEvent, + type FocusableView } from 'ckeditor5/src/ui'; import { FocusTracker, KeystrokeHandler, type Locale } from 'ckeditor5/src/utils'; @@ -38,7 +39,7 @@ export type ColorInputViewOptions = { * * @internal */ -export default class ColorInputView extends View { +export default class ColorInputView extends View implements FocusableView { /** * The value of the input. * @@ -87,6 +88,11 @@ export default class ColorInputView extends View { */ public readonly focusTracker: FocusTracker; + /** + * Helps cycling over focusable children in the input view. + */ + public readonly focusCycler: FocusCycler; + /** * A collection of views that can be focused in the view. */ @@ -114,11 +120,6 @@ export default class ColorInputView extends View { */ protected _stillTyping: boolean; - /** - * Helps cycling over focusable items in the view. - */ - protected readonly _focusCycler: FocusCycler; - /** * Creates an instance of the color input view. * @@ -145,7 +146,7 @@ export default class ColorInputView extends View { this.keystrokes = new KeystrokeHandler(); this._stillTyping = false; - this._focusCycler = new FocusCycler( { + this.focusCycler = new FocusCycler( { focusables: this._focusables, focusTracker: this.focusTracker, keystrokeHandler: this.keystrokes, @@ -181,15 +182,23 @@ export default class ColorInputView extends View { public override render(): void { super.render(); - // Start listening for the keystrokes coming from the dropdown panel view. - this.keystrokes.listenTo( this.dropdownView.panelView.element! ); + [ this.inputView, this.dropdownView.buttonView ].forEach( view => { + this.focusTracker.add( view.element! ); + this._focusables.add( view ); + } ); + + this.keystrokes.listenTo( this.element! ); } /** - * Focuses the input. + * Focuses the view. */ - public focus(): void { - this.inputView.focus(); + public focus( direction: 1 | -1 ): void { + if ( direction === -1 ) { + this.focusCycler.focusLast(); + } else { + this.focusCycler.focusFirst(); + } } /** @@ -250,10 +259,6 @@ export default class ColorInputView extends View { dropdown.panelView.children.add( colorSelector ); dropdown.bind( 'isEnabled' ).to( this, 'isReadOnly', value => !value ); - this._focusables.add( colorSelector ); - - this.focusTracker.add( colorSelector.element! ); - dropdown.on( 'change:isOpen', ( evt, name, isVisible ) => { if ( isVisible ) { colorSelector.updateSelectedColors(); diff --git a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js index 44d1d6e1b11..b9199ce90e8 100644 --- a/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js +++ b/packages/ckeditor5-table/tests/tablecellproperties/ui/tablecellpropertiesview.js @@ -695,10 +695,8 @@ describe( 'table cell properties', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.borderStyleDropdown, view.borderColorInput, - view.borderColorInput.fieldView.dropdownView.buttonView, view.borderWidthInput, view.backgroundInput, - view.backgroundInput.fieldView.dropdownView.buttonView, view.widthInput, view.heightInput, view.paddingInput, @@ -775,6 +773,49 @@ describe( 'table cell properties', () => { sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); + + it( 'providing seamless forward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown button button is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.dropdownView.buttonView.element; + + const spy = sinon.spy( view.borderWidthInput, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'providing seamless backward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown input is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.inputView.element; + + const spy = sinon.spy( view.borderStyleDropdown, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js index 9a9b1b6fe97..d8e12701206 100644 --- a/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js +++ b/packages/ckeditor5-table/tests/tableproperties/ui/tablepropertiesview.js @@ -630,10 +630,8 @@ describe( 'table properties', () => { expect( view._focusables.map( f => f ) ).to.have.members( [ view.borderStyleDropdown, view.borderColorInput, - view.borderColorInput.fieldView.dropdownView.buttonView, view.borderWidthInput, view.backgroundInput, - view.backgroundInput.fieldView.dropdownView.buttonView, view.widthInput, view.heightInput, view.alignmentToolbar, @@ -708,6 +706,49 @@ describe( 'table properties', () => { sinon.assert.calledOnce( keyEvtData.stopPropagation ); sinon.assert.calledOnce( spy ); } ); + + it( 'providing seamless forward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown button button is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.dropdownView.buttonView.element; + + const spy = sinon.spy( view.borderWidthInput, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'providing seamless backward navigation over child views with their own focusable children and focus cyclers', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the border color dropdown input is focused. + view.focusTracker.isFocused = view.borderColorInput.fieldView.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.borderColorInput.element; + view.borderColorInput.fieldView.focusTracker.focusedElement = + view.borderColorInput.fieldView.inputView.element; + + const spy = sinon.spy( view.borderStyleDropdown, 'focus' ); + + view.borderColorInput.fieldView.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); } ); } ); diff --git a/packages/ckeditor5-table/tests/ui/colorinputview.js b/packages/ckeditor5-table/tests/ui/colorinputview.js index 725161e726c..5d7419f2f20 100644 --- a/packages/ckeditor5-table/tests/ui/colorinputview.js +++ b/packages/ckeditor5-table/tests/ui/colorinputview.js @@ -3,7 +3,7 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global Event */ +/* global document, Event */ import ColorInputView from '../../src/ui/colorinputview'; import InputTextView from '@ckeditor/ckeditor5-ui/src/inputtext/inputtextview'; @@ -48,10 +48,12 @@ describe( 'ColorInputView', () => { inputView = view.inputView; removeColorButton = colorSelectorView.colorGridsFragmentView.removeColorButtonView; colorGridView = colorSelectorView.colorGridsFragmentView.staticColorsGrid; + document.body.appendChild( view.element ); } ); afterEach( () => { view.destroy(); + view.element.remove(); } ); describe( 'constructor()', () => { @@ -106,8 +108,8 @@ describe( 'ColorInputView', () => { expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); } ); - it( 'should have #_focusCycler', () => { - expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + it( 'should have #focusCycler', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); } ); describe( 'dropdown', () => { @@ -230,6 +232,12 @@ describe( 'ColorInputView', () => { } ); describe( 'position', () => { + let view; + + afterEach( () => { + view.destroy(); + } ); + it( 'should be SouthWest in LTR', () => { locale.uiLanguageDirection = 'ltr'; view = new ColorInputView( locale, { @@ -252,17 +260,6 @@ describe( 'ColorInputView', () => { expect( view.dropdownView.panelPosition ).to.equal( 'se' ); } ); } ); - - it( 'should register panelView children in #_focusables', () => { - expect( view._focusables.map( f => f ) ).to.have.members( [ - view.dropdownView.panelView.children.first - ] ); - } ); - - it( 'should register panelView children elements in #focusTracker', () => { - expect( view.focusTracker._elements ).to.include( view.dropdownView.panelView.children.first.element ); - expect( view.focusTracker._elements ).to.include( view.dropdownView.panelView.children.last.element ); - } ); } ); describe( 'color grid', () => { @@ -590,10 +587,10 @@ describe( 'ColorInputView', () => { // Mock the remove color button view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view._focusables.first.element; + view.focusTracker.focusedElement = view.inputView.element; // Spy the next view which in this case is the color grid view. - const spy = sinon.spy( view._focusables.last, 'focus' ); + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -613,10 +610,10 @@ describe( 'ColorInputView', () => { // Mock the remove color button view is focused. view.focusTracker.isFocused = true; - view.focusTracker.focusedElement = view._focusables.first.element; + view.focusTracker.focusedElement = view.inputView.element; // Spy the previous view which in this case is the color grid view. - const spy = sinon.spy( view._focusables.last, 'focus' ); + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); view.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( keyEvtData.preventDefault ); @@ -679,6 +676,14 @@ describe( 'ColorInputView', () => { sinon.assert.calledOnce( spy ); } ); + + it( 'should focus the dropdown button if the backwards direction was specified', () => { + const spy = sinon.spy( view.dropdownView.buttonView, 'focus' ); + + view.focus( -1 ); + + sinon.assert.calledOnce( spy ); + } ); } ); describe( 'render()', () => { @@ -692,7 +697,7 @@ describe( 'ColorInputView', () => { view.render(); sinon.assert.calledOnce( spy ); - sinon.assert.calledWithExactly( spy, view.dropdownView.panelView.element ); + sinon.assert.calledWithExactly( spy, view.element ); view.destroy(); } ); diff --git a/packages/ckeditor5-theme-lark/tests/manual/theme.html b/packages/ckeditor5-theme-lark/tests/manual/theme.html index d721d1dedcc..59fa17b8561 100644 --- a/packages/ckeditor5-theme-lark/tests/manual/theme.html +++ b/packages/ckeditor5-theme-lark/tests/manual/theme.html @@ -92,6 +92,9 @@

Button: Responsiveness

Button: Tooltip

+

Button: Spinner

+
+

Dropdown

ListDropdown

diff --git a/packages/ckeditor5-theme-lark/tests/manual/theme.js b/packages/ckeditor5-theme-lark/tests/manual/theme.js index f255f1f2beb..5ecd7cd4dea 100644 --- a/packages/ckeditor5-theme-lark/tests/manual/theme.js +++ b/packages/ckeditor5-theme-lark/tests/manual/theme.js @@ -30,6 +30,7 @@ import checkIcon from '@ckeditor/ckeditor5-core/theme/icons/check.svg'; import cancelIcon from '@ckeditor/ckeditor5-core/theme/icons/cancel.svg'; import SplitButtonView from '@ckeditor/ckeditor5-ui/src/dropdown/button/splitbuttonview'; +import { SpinnerView } from '@ckeditor/ckeditor5-ui'; const locale = new Locale(); @@ -71,6 +72,7 @@ const ui = testUtils.createTestUIView( { 'buttonResponsive2': '#button-responsive-2', 'buttonResponsive3': '#button-responsive-3', 'buttonTooltip': '#button-tooltip', + 'buttonSpinner': '#button-spinner', listDropdown: '#list-dropdown', buttonDropdown: '#button-dropdown', @@ -273,6 +275,22 @@ function renderButton() { tooltipPosition: 'sw' } ) ] ) ); + + // --- With spinner ------------------------------------------------------------ + + const buttonWithSpinner = button( { + label: 'Button with spinner', + withText: false + } ); + + const spinnerView = new SpinnerView(); + spinnerView.isVisible = true; + + buttonWithSpinner.children.add( spinnerView ); + + ui.buttonSpinner.add( toolbar( [ + buttonWithSpinner + ] ) ); } function renderDropdown() { diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css new file mode 100644 index 00000000000..63456470aee --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/autocomplete/autocomplete.css @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-theme-lark/theme/mixins/_rounded.css"; +@import "@ckeditor/ckeditor5-theme-lark/theme/mixins/_shadow.css"; + +.ck.ck-autocomplete { + & > .ck-search__results { + @mixin ck-rounded-corners; + @mixin ck-drop-shadow; + + max-height: 200px; + overflow-y: auto; + background: var(--ck-color-base-background); + border: 1px solid var(--ck-color-dropdown-panel-border); + min-width: auto; + + &.ck-search__results_n { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + + /* Prevent duplicated borders between the input and the results pane. */ + margin-bottom: -1px; + } + + &.ck-search__results_s { + border-top-left-radius: 0; + border-top-right-radius: 0; + + /* Prevent duplicated borders between the input and the results pane. */ + margin-top: -1px; + } + } +} diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css index 9199dd73572..16060d48c07 100644 --- a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/list/list.css @@ -77,6 +77,21 @@ } } +.ck-list .ck-list__group { + padding-top: var(--ck-spacing-medium); + + /* The group should have a border when it's not the first item. */ + *:not(.ck-hidden) ~ & { + border-top: 1px solid var(--ck-color-base-border); + } + + & > span { + font-size: 11px; + font-weight: bold; + padding: var(--ck-spacing-medium); + } +} + .ck.ck-list__separator { height: 1px; width: 100%; diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css new file mode 100644 index 00000000000..9a0e4f52486 --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/search/search.css @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +:root { + --ck-search-field-view-horizontal-spacing: calc(var(--ck-icon-size) + var(--ck-spacing-medium)); +} + +.ck.ck-search { + & > .ck-labeled-field-view { + & .ck-input { + width: 100%; + } + + &.ck-search__query_with-icon { + --ck-labeled-field-label-default-position-x: var(--ck-search-field-view-horizontal-spacing); + + & > .ck-labeled-field-view__input-wrapper > .ck-icon { + opacity: .5; + pointer-events: none; + } + + & .ck-input { + width: 100%; + + @mixin ck-dir ltr { + padding-left: var(--ck-search-field-view-horizontal-spacing); + } + + @mixin ck-dir rtl { + &:not(.ck-input-text_empty) { + padding-left: var(--ck-search-field-view-horizontal-spacing); + } + } + } + } + + &.ck-search__query_with-reset { + --ck-labeled-field-empty-unfocused-max-width: 100% - 2 * var(--ck-search-field-view-horizontal-spacing); + + &.ck-labeled-field-view_empty { + --ck-labeled-field-empty-unfocused-max-width: 100% - var(--ck-search-field-view-horizontal-spacing) - var(--ck-spacing-medium); + } + + & .ck-search__reset { + min-width: auto; + min-height: auto; + + background: none; + opacity: .5; + padding: 0; + + @mixin ck-dir ltr { + right: var(--ck-spacing-medium); + } + + @mixin ck-dir rtl { + left: var(--ck-spacing-medium); + } + + &:hover { + opacity: 1; + } + } + + & .ck-input { + width: 100%; + + @mixin ck-dir ltr { + &:not(.ck-input-text_empty) { + padding-right: var(--ck-search-field-view-horizontal-spacing); + } + } + + @mixin ck-dir rtl { + padding-right: var(--ck-search-field-view-horizontal-spacing); + } + } + } + } + + & > .ck-search__results { + min-width: 100%; + + & > .ck-search__info { + width: 100%; + padding: var(--ck-spacing-medium) var(--ck-spacing-large); + + & * { + white-space: normal; + } + + & > span:first-child { + font-weight: bold; + } + + & > span:last-child { + margin-top: var(--ck-spacing-medium); + } + } + } +} + diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css new file mode 100644 index 00000000000..16223a79ead --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/spinner/spinner.css @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +:root { + --ck-toolbar-spinner-size: 18px; +} + +.ck.ck-spinner-container { + width: var(--ck-toolbar-spinner-size); + height: var(--ck-toolbar-spinner-size); + animation: 1.5s infinite rotate linear; +} + +.ck.ck-spinner { + width: var(--ck-toolbar-spinner-size); + height: var(--ck-toolbar-spinner-size); + border-radius: 50%; + border: 2px solid var(--ck-color-text); + border-top-color: transparent; +} + +@keyframes rotate { + to { + transform: rotate(360deg) + } +} + diff --git a/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css new file mode 100644 index 00000000000..6af487b9f9f --- /dev/null +++ b/packages/ckeditor5-theme-lark/theme/ckeditor5-ui/components/textarea/textarea.css @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* + * This fixes a problem in Firefox when the initial height of the complement does not match the number of rows. + * This bug is especially visible when rows=1. + */ +.ck-textarea { + overflow-x: hidden +} diff --git a/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts new file mode 100644 index 00000000000..fde873ce699 --- /dev/null +++ b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts @@ -0,0 +1,230 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/autocomplete/autocompleteview +*/ + +import { getOptimalPosition, type PositioningFunction, type Locale, global, toUnit, Rect } from '@ckeditor/ckeditor5-utils'; +import SearchTextView, { type SearchTextViewConfig } from '../search/text/searchtextview'; +import type SearchResultsView from '../search/searchresultsview'; +import type InputBase from '../input/inputbase'; +import type { FilteredViewExecuteEvent } from '../search/filteredview'; + +import '../../theme/components/autocomplete/autocomplete.css'; + +/** + * The autocomplete component's view class. It extends the {@link module:ui/search/text/searchtextview~SearchTextView} class + * with a floating {@link #resultsView} that shows up when the user starts typing and hides when they blur + * the component. + */ +export default class AutocompleteView< + TQueryFieldView extends InputBase +> extends SearchTextView { + /** + * The configuration of the autocomplete view. + */ + protected override _config: AutocompleteViewConfig; + + declare public resultsView: AutocompleteResultsView; + + /** + * @inheritDoc + */ + constructor( locale: Locale, config: AutocompleteViewConfig ) { + super( locale, config ); + + this._config = config; + + const toPx = toUnit( 'px' ); + + this.extendTemplate( { + attributes: { + class: [ 'ck-autocomplete' ] + } + } ); + + const bindResultsView = this.resultsView.bindTemplate; + + this.resultsView.set( 'isVisible', false ); + this.resultsView.set( '_position', 's' ); + this.resultsView.set( '_width', 0 ); + + this.resultsView.extendTemplate( { + attributes: { + class: [ + bindResultsView.if( 'isVisible', 'ck-hidden', value => !value ), + bindResultsView.to( '_position', value => `ck-search__results_${ value }` ) + ], + style: { + width: bindResultsView.to( '_width', toPx ) + } + } + } ); + + // Update the visibility of the results view when the user focuses or blurs the component. + // This is also integration for the `resetOnBlur` configuration. + this.focusTracker.on( 'change:isFocused', ( evt, name, isFocused ) => { + this._updateResultsVisibility(); + + if ( isFocused ) { + // Reset the scroll position of the results view whenever the autocomplete reopens. + this.resultsView.element!.scrollTop = 0; + } else if ( config.resetOnBlur ) { + this.queryView.reset(); + } + } ); + + // Update the visibility of the results view when the user types in the query field. + // This is an integration for `queryMinChars` configuration. + // This is an integration for search results changing length and the #resultsView requiring to be repositioned. + this.on( 'search', () => { + this._updateResultsVisibility(); + this._updateResultsViewWidthAndPosition(); + } ); + + // Hide the results view when the user presses the ESC key. + this.keystrokes.set( 'esc', ( evt, cancel ) => { + this.resultsView.isVisible = false; + cancel(); + } ); + + // Update the position of the results view when the user scrolls the page. + // TODO: This needs to be debounced down the road. + this.listenTo( global.document, 'scroll', () => { + this._updateResultsViewWidthAndPosition(); + } ); + + // Hide the results when the component becomes disabled. + this.on( 'change:isEnabled', () => { + this._updateResultsVisibility(); + } ); + + // Update the value of the query field when the user selects a result. + this.filteredView.on( 'execute', ( evt, { value } ) => { + // Focus the query view first to avoid losing the focus. + this.focus(); + + // Resetting the view will ensure that the #queryView will update its empty state correctly. + // This prevents bugs related to dynamic labels or auto-grow when re-setting the same value + // to #queryView.fieldView.value (which does not trigger empty state change) to an + // #queryView.fieldView.element that has been changed by the user. + this.reset(); + + // Update the value of the query field. + this.queryView.fieldView.value = this.queryView.fieldView.element!.value = value; + + // Finally, hide the results view. The focus has been moved earlier so this is safe. + this.resultsView.isVisible = false; + } ); + + // Update the position and width of the results view when it becomes visible. + this.resultsView.on( 'change:isVisible', () => { + this._updateResultsViewWidthAndPosition(); + } ); + } + + /** + * Updates the position of the results view on demand. + */ + private _updateResultsViewWidthAndPosition() { + if ( !this.resultsView.isVisible ) { + return; + } + + this.resultsView._width = new Rect( this.queryView.fieldView.element! ).width; + + const optimalResultsPosition = AutocompleteView._getOptimalPosition( { + element: this.resultsView.element!, + target: this.queryView.element!, + fitInViewport: true, + positions: AutocompleteView.defaultResultsPositions + } ); + + // _getOptimalPosition will return null if there is no optimal position found (e.g. target is off the viewport). + this.resultsView._position = optimalResultsPosition ? optimalResultsPosition.name : 's'; + } + + /** + * Updates the visibility of the results view on demand. + */ + private _updateResultsVisibility() { + const queryMinChars = typeof this._config.queryMinChars === 'undefined' ? 0 : this._config.queryMinChars; + const queryLength = this.queryView.fieldView.element!.value.length; + + this.resultsView.isVisible = this.focusTracker.isFocused && this.isEnabled && queryLength >= queryMinChars; + } + + /** + * Positions for the autocomplete results view. Two positions are defined by default: + * * `s` - below the search field, + * * `n` - above the search field. + */ + public static defaultResultsPositions: Array = [ + ( fieldRect => { + return { + top: fieldRect.bottom, + left: fieldRect.left, + name: 's' + }; + } ) as PositioningFunction, + ( ( fieldRect, resultsRect ) => { + return { + top: fieldRect.top - resultsRect.height, + left: fieldRect.left, + name: 'n' + }; + } ) as PositioningFunction + ]; + + /** + * A function used to calculate the optimal position for the dropdown panel. + */ + private static _getOptimalPosition = getOptimalPosition; +} + +/** + * An interface describing additional properties of the floating search results view used by the autocomplete plugin. + */ +export interface AutocompleteResultsView extends SearchResultsView { + + /** + * Controls the visibility of the results view. + * + * @observable + */ + isVisible: boolean; + + /** + * Controls the position (CSS class suffix) of the results view. + * + * @internal + */ + _position?: string; + + /** + * The observable property determining the CSS width of the results view. + * + * @internal + */ + _width: number; +} + +export interface AutocompleteViewConfig< + TConfigInputCreator extends InputBase +> extends SearchTextViewConfig { + + /** + * When set `true`, the query view will be reset when the autocomplete view loses focus. + */ + resetOnBlur?: boolean; + + /** + * Minimum number of characters that need to be typed before the search is performed. + * + * @default 0 + */ + queryMinChars?: number; +} diff --git a/packages/ckeditor5-ui/src/button/buttonlabel.ts b/packages/ckeditor5-ui/src/button/buttonlabel.ts new file mode 100644 index 00000000000..17d340e56cb --- /dev/null +++ b/packages/ckeditor5-ui/src/button/buttonlabel.ts @@ -0,0 +1,41 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/button/buttonlabel +*/ + +import type View from '../view'; + +/** + * The button label interface. Implemented by the {@link module:ui/button/buttonlabelview~ButtonLabelView} + * and any label view that can be used with the {@link module:ui/button/buttonview~ButtonView}. + */ +export default interface ButtonLabel extends View { + + /** + * The `id` attribute of the button label. It is used for accessibility purposes + * to describe the button. + * + * @observable + */ + id: string | undefined; + + /** + * The `style` attribute of the button label. It allows customizing the presentation + * of the label. + * + * @observable + */ + style: string | undefined; + + /** + * The human-readable text of the label. + * + * @observable + */ + text: string | undefined; + +} diff --git a/packages/ckeditor5-ui/src/button/buttonlabelview.ts b/packages/ckeditor5-ui/src/button/buttonlabelview.ts new file mode 100644 index 00000000000..7a54ef959dd --- /dev/null +++ b/packages/ckeditor5-ui/src/button/buttonlabelview.ts @@ -0,0 +1,66 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/button/buttonlabelview + */ + +import View from '../view'; +import type ButtonLabel from './buttonlabel'; + +/** + * A default implementation of the button view's label. It comes with a dynamic text support + * via {@link module:ui/button/buttonlabelview~ButtonLabelView#text} property. + */ +export default class ButtonLabelView extends View implements ButtonLabel { + /** + * @inheritDoc + */ + declare public id: string | undefined; + + /** + * @inheritDoc + */ + declare public style: string | undefined; + + /** + * @inheritDoc + */ + declare public text: string | undefined; + + /** + * @inheritDoc + */ + constructor() { + super(); + + this.set( { + style: undefined, + text: undefined, + id: undefined + } ); + + const bind = this.bindTemplate; + + this.setTemplate( { + tag: 'span', + + attributes: { + class: [ + 'ck', + 'ck-button__label' + ], + style: bind.to( 'style' ), + id: bind.to( 'id' ) + }, + + children: [ + { + text: bind.to( 'text' ) + } + ] + } ); + } +} diff --git a/packages/ckeditor5-ui/src/button/buttonview.ts b/packages/ckeditor5-ui/src/button/buttonview.ts index d2de2ed9eb2..10a26a735db 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.ts +++ b/packages/ckeditor5-ui/src/button/buttonview.ts @@ -13,6 +13,8 @@ import IconView from '../icon/iconview'; import type { TemplateDefinition } from '../template'; import type ViewCollection from '../viewcollection'; import type { default as Button, ButtonExecuteEvent } from './button'; +import type ButtonLabel from './buttonlabel'; +import ButtonLabelView from './buttonlabelview'; import { env, @@ -50,9 +52,12 @@ export default class ButtonView extends View implements Butto public readonly children: ViewCollection; /** - * Label of the button view. It is configurable using the {@link #label label attribute}. + * Label of the button view. Its text is configurable using the {@link #label label attribute}. + * + * If not configured otherwise in the `constructor()`, by default the label is an instance + * of {@link module:ui/button/buttonlabelview~ButtonLabelView}. */ - public readonly labelView: View; + public readonly labelView: ButtonLabel; /** * The icon view of the button. Will be added to {@link #children} when the @@ -178,9 +183,13 @@ export default class ButtonView extends View implements Butto private _focusDelayed: DelayedFunc<() => void> | null = null; /** - * @inheritDoc + * Creates an instance of the button view class. + * + * @param locale The {@link module:core/editor/editor~Editor#locale} instance. + * @param labelView The instance of the button's label. If not provided, an instance of + * {@link module:ui/button/buttonlabelview~ButtonLabelView} is used. */ - constructor( locale?: Locale ) { + constructor( locale?: Locale, labelView: ButtonLabel = new ButtonLabelView() ) { super( locale ); const bind = this.bindTemplate; @@ -208,7 +217,7 @@ export default class ButtonView extends View implements Butto this.set( 'withKeystroke', false ); this.children = this.createCollection(); - this.labelView = this._createLabelView(); + this.labelView = this._setupLabelView( labelView ); this.iconView = new IconView(); this.iconView.extendTemplate( { @@ -325,30 +334,10 @@ export default class ButtonView extends View implements Butto } /** - * Creates a label view instance and binds it with button attributes. + * Binds the label view instance it with button attributes. */ - private _createLabelView() { - const labelView = new View(); - const bind = this.bindTemplate; - - labelView.setTemplate( { - tag: 'span', - - attributes: { - class: [ - 'ck', - 'ck-button__label' - ], - style: bind.to( 'labelStyle' ), - id: this.ariaLabelledBy - }, - - children: [ - { - text: bind.to( 'label' ) - } - ] - } ); + private _setupLabelView( labelView: ButtonLabelView ) { + labelView.bind( 'text', 'style', 'id' ).to( this, 'label', 'labelStyle', 'ariaLabelledBy' ); return labelView; } diff --git a/packages/ckeditor5-ui/src/dropdown/utils.ts b/packages/ckeditor5-ui/src/dropdown/utils.ts index 8a4130c88fa..06b9dac09f2 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.ts +++ b/packages/ckeditor5-ui/src/dropdown/utils.ts @@ -39,6 +39,7 @@ import { import '../../theme/components/dropdown/toolbardropdown.css'; import '../../theme/components/dropdown/listdropdown.css'; +import ListItemGroupView from '../list/listitemgroupview'; /** * A helper for creating dropdowns. It creates an instance of a {@link module:ui/dropdown/dropdownview~DropdownView dropdown}, @@ -345,38 +346,14 @@ function addListToOpenDropdown( role?: string; } ): void { - const locale = dropdownView.locale; - + const locale = dropdownView.locale!; const listView = dropdownView.listView = new ListView( locale ); const items = typeof itemsOrCallback == 'function' ? itemsOrCallback() : itemsOrCallback; listView.ariaLabel = options.ariaLabel; listView.role = options.role; - listView.items.bindTo( items ).using( def => { - if ( def.type === 'separator' ) { - return new ListSeparatorView( locale ); - } else if ( def.type === 'button' || def.type === 'switchbutton' ) { - const listItemView = new ListItemView( locale ); - let buttonView; - - if ( def.type === 'button' ) { - buttonView = new ButtonView( locale ); - } else { - buttonView = new SwitchButtonView( locale ); - } - - // Bind all model properties to the button view. - buttonView.bind( ...Object.keys( def.model ) as Array ).to( def.model ); - buttonView.delegate( 'execute' ).to( listItemView ); - - listItemView.children.add( buttonView ); - - return listItemView; - } - - return null; - } ); + bindViewCollectionItemsToDefinitions( dropdownView, listView.items, items, locale ); dropdownView.panelView.children.add( listView ); @@ -547,11 +524,63 @@ function focusDropdownPanelOnOpen( dropdownView: DropdownView ) { }, { priority: 'low' } ); } +/** + * This helper populates a dropdown list with items and groups according to the + * collection of item definitions. A permanent binding is created in this process allowing + * dynamic management of the dropdown list content. + * + * @param dropdownView + * @param listItems + * @param definitions + * @param locale + */ +function bindViewCollectionItemsToDefinitions( + dropdownView: DropdownView, + listItems: ViewCollection, + definitions: Collection, + locale: Locale +) { + listItems.bindTo( definitions ).using( def => { + if ( def.type === 'separator' ) { + return new ListSeparatorView( locale ); + } else if ( def.type === 'group' ) { + const groupView = new ListItemGroupView( locale ); + + groupView.set( { label: def.label } ); + + bindViewCollectionItemsToDefinitions( dropdownView, groupView.items, def.items, locale ); + + groupView.items.delegate( 'execute' ).to( dropdownView ); + + return groupView; + } else if ( def.type === 'button' || def.type === 'switchbutton' ) { + const listItemView = new ListItemView( locale ); + let buttonView; + + if ( def.type === 'button' ) { + buttonView = new ButtonView( locale ); + } else { + buttonView = new SwitchButtonView( locale ); + } + + // Bind all model properties to the button view. + buttonView.bind( ...Object.keys( def.model ) as Array ).to( def.model ); + buttonView.delegate( 'execute' ).to( listItemView ); + + listItemView.children.add( buttonView ); + + return listItemView; + } + + return null; + } ); +} + /** * A definition of the list item used by the {@link module:ui/dropdown/utils~addListToDropdown} * utility. */ -export type ListDropdownItemDefinition = ListDropdownSeparatorDefinition | ListDropdownButtonDefinition; +export type ListDropdownItemDefinition = ListDropdownSeparatorDefinition | ListDropdownButtonDefinition | ListDropdownGroupDefinition; /** * A definition of the 'separator' list item. @@ -571,3 +600,20 @@ export type ListDropdownButtonDefinition = { */ model: Model; }; + +/** + * A definition of the group inside the list. A group can contain one or more list items (buttons). + */ +export type ListDropdownGroupDefinition = { + type: 'group'; + + /** + * The visible label of the group. + */ + label: string; + + /** + * The collection of the child list items inside this group. + */ + items: Collection; +}; diff --git a/packages/ckeditor5-ui/src/focuscycler.ts b/packages/ckeditor5-ui/src/focuscycler.ts index 052e4f6ba10..2cf4b37456b 100644 --- a/packages/ckeditor5-ui/src/focuscycler.ts +++ b/packages/ckeditor5-ui/src/focuscycler.ts @@ -11,7 +11,8 @@ import { isVisible, type ArrayOrItem, type FocusTracker, - type KeystrokeHandler + type KeystrokeHandler, + EmitterMixin } from '@ckeditor/ckeditor5-utils'; import type View from './view'; @@ -69,7 +70,7 @@ import type ViewCollection from './viewcollection'; * * Check out the {@glink framework/deep-dive/ui/focus-tracking "Deep dive into focus tracking"} guide to learn more. */ -export default class FocusCycler { +export default class FocusCycler extends EmitterMixin() { /** * A {@link module:ui/view~View view} collection that the cycler operates on. */ @@ -116,6 +117,8 @@ export default class FocusCycler { keystrokeHandler?: KeystrokeHandler; actions?: FocusCyclerActions; } ) { + super(); + this.focusables = options.focusables; this.focusTracker = options.focusTracker; this.keystrokeHandler = options.keystrokeHandler; @@ -137,6 +140,9 @@ export default class FocusCycler { } } } + + this.on( 'forwardCycle', () => this.focusFirst(), { priority: 'low' } ); + this.on( 'backwardCycle', () => this.focusLast(), { priority: 'low' } ); } /** @@ -210,7 +216,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusFirst(): void { - this._focus( this.first ); + this._focus( this.first, 1 ); } /** @@ -219,7 +225,7 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusLast(): void { - this._focus( this.last ); + this._focus( this.last, -1 ); } /** @@ -228,7 +234,17 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusNext(): void { - this._focus( this.next ); + const next = this.next; + + if ( next && this.focusables.getIndex( next ) === this.current ) { + return; + } + + if ( next === this.first ) { + this.fire( 'forwardCycle' ); + } else { + this._focus( next, 1 ); + } } /** @@ -237,15 +253,29 @@ export default class FocusCycler { * **Note**: Hidden views (e.g. with `display: none`) are ignored. */ public focusPrevious(): void { - this._focus( this.previous ); + const previous = this.previous; + + if ( previous && this.focusables.getIndex( previous ) === this.current ) { + return; + } + + if ( previous === this.last ) { + this.fire( 'backwardCycle' ); + } else { + this._focus( previous, -1 ); + } } /** * Focuses the given view if it exists. + * + * @param view The view to be focused + * @param direction The direction of the focus if the view has focusable children. + * @returns */ - private _focus( view: FocusableView | null ) { + private _focus( view: FocusableView | null, direction: 1 | -1 ) { if ( view ) { - view.focus(); + view.focus( direction ); } } @@ -277,7 +307,7 @@ export default class FocusCycler { const view = this.focusables.get( index )!; if ( isFocusable( view ) ) { - return view as FocusableView; + return view; } // Cycle in both directions. @@ -288,7 +318,24 @@ export default class FocusCycler { } } -export type FocusableView = View & { focus(): void }; +/** + * A view that can be focused. + */ +export type FocusableView = View & { + + /** + * Focuses the view. + * + * @param direction This optional parameter helps improve the UX by providing additional information about the direction the focus moved + * (e.g. in a complex view or a form). It is useful for views that host multiple focusable children (e.g. lists, toolbars): + * * `1` indicates that the focus moved forward and, in most cases, the first child of the focused view should get focused, + * * `-1` indicates that the focus moved backwards, and the last focusable child should get focused + * + * See {@link module:ui/focuscycler~FocusCycler#forwardCycle} and {@link module:ui/focuscycler~FocusCycler#backwardCycle} to + * learn more. + */ + focus( direction?: 1 | -1 ): void; +}; export interface FocusCyclerActions { focusFirst?: ArrayOrItem; @@ -297,11 +344,33 @@ export interface FocusCyclerActions { focusPrevious?: ArrayOrItem; } +/** + * Fired when the focus cycler is about to move the focus from the last focusable item + * to the first one. + * + * @eventName ~FocusCycler#forwardCycle + */ +export type FocusCyclerForwardCycleEvent = { + name: 'forwardCycle'; + args: []; +}; + +/** + * Fired when the focus cycler is about to move the focus from the first focusable item + * to the last one. + * + * @eventName ~FocusCycler#backwardCycle + */ +export type FocusCyclerBackwardCycleEvent = { + name: 'backwardCycle'; + args: []; +}; + /** * Checks whether a view is focusable. * * @param view A view to be checked. */ -function isFocusable( view: View & { focus?: unknown } ) { - return !!( view.focus && isVisible( view.element ) ); +function isFocusable( view: View ): view is FocusableView { + return !!( 'focus' in view && isVisible( view.element ) ); } diff --git a/packages/ckeditor5-ui/src/formheader/formheaderview.ts b/packages/ckeditor5-ui/src/formheader/formheaderview.ts index 540842db7a2..50d39abc6f1 100644 --- a/packages/ckeditor5-ui/src/formheader/formheaderview.ts +++ b/packages/ckeditor5-ui/src/formheader/formheaderview.ts @@ -9,6 +9,7 @@ import View from '../view'; import type ViewCollection from '../viewcollection'; +import IconView from '../icon/iconview'; import type { Locale } from '@ckeditor/ckeditor5-utils'; @@ -47,6 +48,11 @@ export default class FormHeaderView extends View { */ public declare class: string | null; + /** + * The icon view instance. Defined only if icon was passed in the constructor's options. + */ + public readonly iconView?: IconView; + /** * Creates an instance of the form header class. * @@ -56,7 +62,11 @@ export default class FormHeaderView extends View { */ constructor( locale: Locale | undefined, - options: { label?: string | null; class?: string | null } = {} + options: { + label?: string | null; + class?: string | null; + icon?: string | null; + } = {} ) { super( locale ); @@ -79,6 +89,13 @@ export default class FormHeaderView extends View { children: this.children } ); + if ( options.icon ) { + this.iconView = new IconView(); + this.iconView.content = options.icon; + + this.children.add( this.iconView ); + } + const label = new View( locale ); label.setTemplate( { diff --git a/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts b/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts new file mode 100644 index 00000000000..f93d007ebbe --- /dev/null +++ b/packages/ckeditor5-ui/src/highlightedtext/highlightedtextview.ts @@ -0,0 +1,129 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/highlightedtext/highlightedtextview + */ + +import View from '../view'; +import { escape } from 'lodash-es'; + +import '../../theme/components/highlightedtext/highlightedtext.css'; + +/** + * A class representing a view that displays a text which subset can be highlighted using the + * {@link #highlightText} method. + */ +export default class HighlightedTextView extends View { + /** + * The text that can be highlighted using the {@link #highlightText} method. + * + * **Note:** When this property changes, the previous highlighting is removed. + * + * @observable + */ + declare public text: string | undefined; + + /** + * @inheritDoc + */ + constructor() { + super(); + + this.set( 'text', undefined ); + + this.setTemplate( { + tag: 'span', + attributes: { + class: [ 'ck', 'ck-highlighted-text' ] + } + } ); + + this.on( 'render', () => { + // Classic setTemplate binding for #text will not work because highlightText() replaces the + // pre-rendered DOM text node new a new one (and elements). + this.on( 'change:text', () => { + this._updateInnerHTML( this.text ); + } ); + + this._updateInnerHTML( this.text ); + } ); + } + + /** + * Highlights view's {@link #text} according to the specified `RegExp`. If the passed RegExp is `null`, the + * highlighting is removed + * + * @param regExp + */ + public highlightText( regExp: RegExp | null ): void { + this._updateInnerHTML( markText( this.text || '', regExp ) ); + } + + /** + * Updates element's `innerHTML` with the passed content. + */ + private _updateInnerHTML( newInnerHTML: string | undefined ) { + this.element!.innerHTML = newInnerHTML || ''; + } +} + +/** + * Replaces `regExp` occurrences with `` tags in a text. + * + * @param text A text to get marked. + * @param regExp An optional `RegExp`. If not passed, this is a pass-through function. + * @returns A text with `RegExp` occurrences marked by ``. + */ +function markText( text: string, regExp?: RegExp | null ) { + if ( !regExp ) { + return escape( text ); + } + + const textParts: Array<{ text: string; isMatch: boolean }> = []; + let lastMatchEnd = 0; + let matchInfo = regExp.exec( text ); + + // Iterate over all matches and create an array of text parts. The idea is to mark which parts are query matches + // so that later on they can be highlighted. + while ( matchInfo !== null ) { + const curMatchStart = matchInfo.index; + // Detect if there was something between last match and this one. + if ( curMatchStart !== lastMatchEnd ) { + textParts.push( { + text: text.substring( lastMatchEnd, curMatchStart ), + isMatch: false + } ); + } + + textParts.push( { + text: matchInfo[ 0 ], + isMatch: true + } ); + + lastMatchEnd = regExp.lastIndex; + matchInfo = regExp.exec( text ); + } + + // Your match might not be the last part of a string. Be sure to add any plain text following the last match. + if ( lastMatchEnd !== text.length ) { + textParts.push( { + text: text.substring( lastMatchEnd ), + isMatch: false + } ); + } + + const outputHtml = textParts + // The entire text should be escaped. + .map( part => { + part.text = escape( part.text ); + return part; + } ) + // Only matched text should be wrapped with HTML mark element. + .map( part => part.isMatch ? `${ part.text }` : part.text ) + .join( '' ); + + return outputHtml; +} diff --git a/packages/ckeditor5-ui/src/icon/iconview.ts b/packages/ckeditor5-ui/src/icon/iconview.ts index 505b462abde..8e65e30e370 100644 --- a/packages/ckeditor5-ui/src/icon/iconview.ts +++ b/packages/ckeditor5-ui/src/icon/iconview.ts @@ -64,6 +64,14 @@ export default class IconView extends View { */ declare public isColorInherited: boolean; + /** + * Controls whether the icon is visible. + * + * @observable + * @default true + */ + declare public isVisible: boolean; + /** * A list of presentational attributes that can be set on the `` element and should be preserved * when the icon {@link module:ui/icon/iconview~IconView#content content} is loaded. @@ -93,6 +101,7 @@ export default class IconView extends View { this.set( 'viewBox', '0 0 20 20' ); this.set( 'fillColor', '' ); this.set( 'isColorInherited', true ); + this.set( 'isVisible', true ); this.setTemplate( { tag: 'svg', @@ -101,6 +110,7 @@ export default class IconView extends View { class: [ 'ck', 'ck-icon', + bind.if( 'isVisible', 'ck-hidden', value => !value ), // Exclude icon internals from the CSS reset to allow rich (non-monochromatic) icons // (https://github.com/ckeditor/ckeditor5/issues/12599). diff --git a/packages/ckeditor5-ui/src/index.ts b/packages/ckeditor5-ui/src/index.ts index 36185b2cace..a269d0a8bf1 100644 --- a/packages/ckeditor5-ui/src/index.ts +++ b/packages/ckeditor5-ui/src/index.ts @@ -16,7 +16,9 @@ export { default as addKeyboardHandlingForGrid } from './bindings/addkeyboardhan export { default as BodyCollection } from './editorui/bodycollection'; export { type ButtonExecuteEvent } from './button/button'; +export { type default as ButtonLabel } from './button/buttonlabel'; export { default as ButtonView } from './button/buttonview'; +export { default as ButtonLabelView } from './button/buttonlabelview'; export { default as SwitchButtonView } from './button/switchbuttonview'; export * from './colorgrid/utils'; @@ -47,19 +49,27 @@ export { default as BoxedEditorUIView } from './editorui/boxed/boxededitoruiview export { default as InlineEditableUIView } from './editableui/inline/inlineeditableuiview'; export { default as FormHeaderView } from './formheader/formheaderview'; -export { default as FocusCycler, type FocusableView } from './focuscycler'; +export { + default as FocusCycler, + type FocusableView, + type FocusCyclerForwardCycleEvent, + type FocusCyclerBackwardCycleEvent +} 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 InputNumberView } from './inputnumber/inputnumberview'; +export { default as TextareaView, type TextareaViewUpdateEvent } from './textarea/textareaview'; + export { default as IframeView } from './iframe/iframeview'; export { default as LabelView } from './label/labelview'; -export { default as LabeledFieldView } from './labeledfield/labeledfieldview'; +export { type LabeledFieldViewCreator, default as LabeledFieldView } from './labeledfield/labeledfieldview'; export * from './labeledfield/utils'; +export { default as ListItemGroupView } from './list/listitemgroupview'; export { default as ListItemView } from './list/listitemview'; export { default as ListView } from './list/listview'; @@ -70,9 +80,17 @@ export { default as BalloonPanelView } from './panel/balloon/balloonpanelview'; export { default as ContextualBalloon } from './panel/balloon/contextualballoon'; export { default as StickyPanelView } from './panel/sticky/stickypanelview'; +export { default as AutocompleteView, type AutocompleteViewConfig, type AutocompleteResultsView } from './autocomplete/autocompleteview'; +export { default as SearchTextView, type SearchTextViewSearchEvent, type SearchTextViewConfig } from './search/text/searchtextview'; +export { default as SearchInfoView } from './search/searchinfoview'; +export { default as FilteredView, type FilteredViewExecuteEvent } from './search/filteredview'; +export { default as HighlightedTextView } from './highlightedtext/highlightedtextview'; + export { default as TooltipManager } from './tooltipmanager'; export { default as Template, type TemplateDefinition } from './template'; +export { default as SpinnerView } from './spinner/spinnerview'; + export { default as ToolbarView } from './toolbar/toolbarview'; export { default as ToolbarLineBreakView } from './toolbar/toolbarlinebreakview'; export { default as ToolbarSeparatorView } from './toolbar/toolbarseparatorview'; diff --git a/packages/ckeditor5-ui/src/input/inputbase.ts b/packages/ckeditor5-ui/src/input/inputbase.ts new file mode 100644 index 00000000000..a24f5e93f15 --- /dev/null +++ b/packages/ckeditor5-ui/src/input/inputbase.ts @@ -0,0 +1,205 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module ui/input/inputbase + */ + +import View from '../view'; + +import { + FocusTracker, + type Locale, + type ObservableChangeEvent +} from '@ckeditor/ckeditor5-utils'; + +/** + * The base input view class. + */ +export default abstract class InputBase extends View { + /** + * Stores information about the editor UI focus and propagates it so various plugins and components + * are unified as a focus group. + */ + public readonly focusTracker: FocusTracker; + + /** + * The value of the input. + * + * @observable + */ + declare public value: string | undefined; + + /** + * The `id` attribute of the input (i.e. to pair with a `
+ + diff --git a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md new file mode 100644 index 00000000000..1f260145fcd --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.md @@ -0,0 +1,6 @@ +# AutoCompleteView component + +1. **Note**: The component is rendered some distance from the top of the screen. +2. Make sure the results show up as soon as you start typing. +3. Make sure you can move focus between the field and results using Tab/Shift+Tab. +4. Scroll the viewport: The results should show up below or above the input depending on the available space on the screen. diff --git a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts new file mode 100644 index 00000000000..a2567f1f32e --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts @@ -0,0 +1,77 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import { + ButtonView, + ListItemView, + ListView, + AutocompleteView, + type FilteredView, + type FilteredViewExecuteEvent +} from '../../../src'; + +const locale = new Locale(); + +class FilteredTestListView extends ListView implements FilteredView { + public filter( query ) { + let visibleItems = 0; + + for ( const item of this.items ) { + const listItemView = ( item as ListItemView ); + const buttonView = listItemView.children.first! as ButtonView; + + listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( listItemView.isVisible ) { + visibleItems++; + } + } + + return { + resultsCount: visibleItems, + totalItemsCount: this.items.length + }; + } +} + +const listView = new FilteredTestListView(); + +[ + 'getAttribute()', 'getAttributeNames()', 'getAttributeNode()', 'getAttributeNodeNS()', 'getAttributeNS()', + 'getBoundingClientRect()', 'getClientRects()', 'getElementsByClassName()', 'getElementsByTagName()', 'getElementsByTagNameNS()', + 'hasAttribute()', 'hasAttributeNS()', 'hasAttributes()', 'hasPointerCapture()', 'insertAdjacentElement()', 'insertAdjacentHTML()', + 'insertAdjacentText()', 'matches()', 'prepend()', 'querySelector()', 'querySelectorAll()', 'releasePointerCapture()', 'remove()', + 'removeAttribute()', 'removeAttributeNode()', 'removeAttributeNS()' +].forEach( item => { + const listItemView = new ListItemView(); + const buttonView = new ButtonView(); + + buttonView.on( 'execute', () => { + listView.fire( 'execute', { + value: buttonView.label! + } ); + } ); + + buttonView.withText = true; + buttonView.label = item; + listItemView.children.add( buttonView ); + listView.items.add( listItemView ); +} ); + +const view = new AutocompleteView( locale, { + queryView: { + label: 'Search field label', + showIcon: false, + showResetButton: false + }, + filteredView: listView +} ); + +view.render(); + +document.querySelector( '.playground' )!.appendChild( view.element! ); diff --git a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html index 70104b6ac55..049b8aa85e8 100644 --- a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html +++ b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.html @@ -6,6 +6,11 @@

Dropdown with ListView

+

Dropdown with ListView (and groups)

+ +
+ +

Long label (truncated)

diff --git a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js index 707a46d3e48..b152c0d5a8d 100644 --- a/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js +++ b/packages/ckeditor5-ui/tests/manual/dropdown/dropdown.js @@ -21,6 +21,7 @@ import { createDropdown, addToolbarToDropdown, addListToDropdown } from '../../. const ui = testUtils.createTestUIView( { dropdown: '#dropdown', listDropdown: '#list-dropdown', + listDropdownWithGroups: '#list-dropdown-with-groups', dropdownLabel: '#dropdown-label', toolbarDropdown: '#dropdown-toolbar', splitButton: '#dropdown-splitbutton' @@ -76,6 +77,80 @@ function testList() { window.Model = Model; } +function testListWithGroups() { + const collection = new Collection( { idProperty: 'label' } ); + + collection.addMany( [ + { + type: 'button', + model: new Model( { + label: 'Item 1', + withText: true + } ) + }, + { + type: 'group', + label: 'Group 1', + items: new Collection( [ + { + type: 'button', + model: new Model( { + label: 'Group 1, Item 1', + withText: true + } ) + }, + { + type: 'button', + model: new Model( { + label: 'Group 1, Item 1', + withText: true + } ) + } + ] ) + }, + { + type: 'group', + label: 'Group 2', + items: new Collection( [ + { + type: 'button', + model: new Model( { + label: 'Group 2, Item 1', + withText: true + } ) + }, + { + type: 'button', + model: new Model( { + label: 'Group 2, Item 1', + withText: true + } ) + } + ] ) + } + ] ); + + const dropdownView = createDropdown( {} ); + + dropdownView.buttonView.set( { + label: 'ListDropdown (with groups)', + isEnabled: true, + isOn: false, + withText: true + } ); + + addListToDropdown( dropdownView, collection ); + + dropdownView.on( 'execute', evt => { + console.log( 'List#execute:', evt.source.label ); + } ); + + ui.listDropdownWithGroups.add( dropdownView ); + + window.listDropdownWithGroupsCollection = collection; + window.Model = Model; +} + function testLongLabel() { const dropdownView = createDropdown( {} ); @@ -160,6 +235,7 @@ function testSplitButton() { testEmpty(); testList(); +testListWithGroups(); testLongLabel(); testToolbar(); testSplitButton(); diff --git a/packages/ckeditor5-ui/tests/manual/list/list.html b/packages/ckeditor5-ui/tests/manual/list/list.html new file mode 100644 index 00000000000..24a9b97abbb --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.html @@ -0,0 +1,13 @@ +
+ + diff --git a/packages/ckeditor5-ui/tests/manual/list/list.js b/packages/ckeditor5-ui/tests/manual/list/list.js new file mode 100644 index 00000000000..f95c894b91f --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.js @@ -0,0 +1,60 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { ButtonView, ListItemGroupView, ListItemView, ListView } from '../../../src'; + +const playground = document.querySelector( '#playground' ); + +const defaultView = new ListView(); +defaultView.render(); +playground.appendChild( defaultView.element ); + +const grouppedView = new ListView(); +grouppedView.render(); +playground.appendChild( grouppedView.element ); + +defaultView.items.addMany( [ + createItem( 'Item 1' ), + createItem( 'Item 2' ), + createItem( 'Item 3' ), + createItem( 'Item 4' ), + createItem( 'Item 5' ) +] ); + +grouppedView.items.addMany( [ + createItem( 'Item 1' ), + createItem( 'Item 2' ), + createGroup( 'Items group 1', [ + createItem( 'Item 1.1' ), + createItem( 'Item 1.2' ) + ] ), + createGroup( 'Items group 2', [ + createItem( 'Item 2.1' ), + createItem( 'Item 2.2' ) + ] ) +] ); + +function createItem( label ) { + const item = new ListItemView(); + const button = new ButtonView(); + + item.children.add( button ); + + button.set( { label, withText: true } ); + + return item; +} + +function createGroup( label, items ) { + const groupView = new ListItemGroupView(); + + groupView.label = label; + groupView.items.addMany( items ); + + return groupView; +} + diff --git a/packages/ckeditor5-ui/tests/manual/list/list.md b/packages/ckeditor5-ui/tests/manual/list/list.md new file mode 100644 index 00000000000..3f0a817bd35 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/list/list.md @@ -0,0 +1,5 @@ +# ListView component + +1. There are two examples of a list in this test. +2. Make sure both render correctly, especially the one with the item groups. +3. Verify accessibility: click the first item and then navigate across the list. The navigation should be smooth, all items should get focus in the right order. diff --git a/packages/ckeditor5-ui/tests/manual/search/search.html b/packages/ckeditor5-ui/tests/manual/search/search.html new file mode 100644 index 00000000000..295ff3a57de --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.html @@ -0,0 +1,31 @@ +
+ + diff --git a/packages/ckeditor5-ui/tests/manual/search/search.md b/packages/ckeditor5-ui/tests/manual/search/search.md new file mode 100644 index 00000000000..d8ef709ac6e --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.md @@ -0,0 +1,5 @@ +# SearchTextView component + +1. There are 3 examples of the component in this test. +2. Make sure searching works and the list/toolbar gets filtered. +3. Check accessibility, make sure Tab/Shift+Tab navigation works. diff --git a/packages/ckeditor5-ui/tests/manual/search/search.ts b/packages/ckeditor5-ui/tests/manual/search/search.ts new file mode 100644 index 00000000000..9aed3b490a9 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/search/search.ts @@ -0,0 +1,210 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import { + ButtonView, + ListItemGroupView, + ListItemView, + ListView, + SearchTextView, + ToolbarView, + type FilteredView, + createLabeledTextarea +} from '../../../src'; + +const locale = new Locale(); + +function createSearchableList() { + class FilteredTestListView extends ListView implements FilteredView { + public filter( query ) { + let totalItemsCount = 0; + let visibleItemsCount = 0; + + function updateListItemVisibility( listItemView: ListItemView ) { + const buttonView = listItemView.children.first! as ButtonView; + + listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( listItemView.isVisible ) { + visibleItemsCount++; + } + + totalItemsCount++; + } + + for ( const listItemOrGroupView of this.items ) { + if ( listItemOrGroupView instanceof ListItemView ) { + updateListItemVisibility( listItemOrGroupView ); + } else { + const groupView = listItemOrGroupView as ListItemGroupView; + + for ( const item of groupView.items ) { + updateListItemVisibility( item as ListItemView ); + } + + groupView.isVisible = !!groupView.items.filter( listItemView => listItemView.isVisible ).length; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount + }; + } + } + + const listView = new FilteredTestListView(); + const hasGroupView = new ListItemGroupView(); + const getGroupView = new ListItemGroupView(); + + hasGroupView.label = 'Starting with "has"...'; + getGroupView.label = 'Starting with "get"...'; + + [ + 'getAttribute()', 'getAttributeNames()', 'getAttributeNode()', 'getAttributeNodeNS()', 'getAttributeNS()', + 'getBoundingClientRect()', 'getClientRects()', 'getElementsByClassName()', 'getElementsByTagName()', 'getElementsByTagNameNS()', + 'hasAttribute()', 'hasAttributeNS()', 'hasAttributes()', 'hasPointerCapture()', 'releasePointerCapture()', 'remove()', + 'removeAttribute()', 'removeAttributeNode()', 'removeAttributeNS()' + ].forEach( item => { + const listItemView = new ListItemView(); + const buttonView = new ButtonView(); + + buttonView.withText = true; + buttonView.label = item; + listItemView.children.add( buttonView ); + + if ( item.startsWith( 'has' ) ) { + hasGroupView.items.add( listItemView ); + } else if ( item.startsWith( 'get' ) ) { + getGroupView.items.add( listItemView ); + } else { + listView.items.add( listItemView ); + } + } ); + + listView.items.add( getGroupView ); + listView.items.add( hasGroupView ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search list items' + }, + filteredView: listView + } ); + + addToPlayground( 'Filtering a list with grouped items', searchView ); +} + +function createSearchableToolbar() { + class FilteredTestToolbarView extends ToolbarView implements FilteredView { + public filter( query ) { + let visibleItemsCount = 0; + + for ( const item of this.items ) { + const buttonView = ( item as ButtonView ); + + buttonView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( buttonView.isVisible ) { + visibleItemsCount++; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount: this.items.length + }; + } + } + + const toolbarView = new FilteredTestToolbarView( locale ); + + [ + 'AddEventListenerOptions', 'AesCbcParams', 'AesCtrParams', 'AesDerivedKeyParams', 'AesGcmParams', 'AesKeyAlgorithm', + 'AesKeyGenParams', 'Algorithm', 'AnalyserOptions', 'AnimationEventInit', 'AnimationPlaybackEventInit', 'AssignedNodesOptions', + 'AudioBufferOptions', 'AudioBufferSourceOptions', 'AudioConfiguration', 'AudioContextOptions', 'AudioNodeOptions', + 'AudioProcessingEventInit', 'AudioTimestamp', 'MediaTrackConstraints', + 'MediaTrackSettings', 'MediaTrackSupportedConstraints', 'MessageEventInit', 'MouseEventInit', 'MultiCacheQueryOptions', + 'MutationObserverInit', 'NavigationPreloadState' + ].forEach( item => { + const buttonView = new ButtonView(); + + buttonView.withText = true; + buttonView.label = item; + toolbarView.items.add( buttonView ); + } ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search toolbar buttons' + }, + filteredView: toolbarView + } ); + + addToPlayground( 'Filtering a toolbar', searchView ); +} + +function createSearchWithCustomInput() { + class FilteredTestToolbarView extends ToolbarView implements FilteredView { + public filter( query ) { + let visibleItemsCount = 0; + + for ( const item of this.items ) { + const buttonView = ( item as ButtonView ); + + buttonView.isVisible = query ? !!buttonView.label!.match( query ) : true; + + if ( buttonView.isVisible ) { + visibleItemsCount++; + } + } + + return { + resultsCount: visibleItemsCount, + totalItemsCount: this.items.length + }; + } + } + + const toolbarView = new FilteredTestToolbarView( locale ); + + Array.from( Array( 30 ).keys() ) + .forEach( item => { + const buttonView = new ButtonView( locale ); + + buttonView.withText = true; + buttonView.label = String( item ); + toolbarView.items.add( buttonView ); + } ); + + const searchView = new SearchTextView( locale, { + queryView: { + label: 'Search toolbar buttons', + creator: createLabeledTextarea + }, + filteredView: toolbarView + } ); + + addToPlayground( 'Custom input (textarea)', searchView ); +} + +function addToPlayground( name, view ) { + view.render(); + + const container = document.createElement( 'div' ); + const heading = document.createElement( 'h2' ); + heading.textContent = name; + + container.appendChild( heading ); + container.appendChild( view.element! ); + document.querySelector( '.playground' )!.appendChild( container ); +} + +createSearchableList(); +createSearchableToolbar(); +createSearchWithCustomInput(); diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html new file mode 100644 index 00000000000..93a920d9c5d --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.html @@ -0,0 +1,42 @@ +
+ + diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md new file mode 100644 index 00000000000..4ed9c41deb8 --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.md @@ -0,0 +1,8 @@ +# `TextareaView` + +There's a number of different view configurations showcased in this test. + +1. Make sure the view resizes according to the configured min and max rows. +2. Try removing all content. Or pasting a lot of content into each textarea. +3. Try resizing textarea manually, it should act according to the description. + * **Known bug**: Typing in a once manually resized textarea will reset it's size. diff --git a/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts new file mode 100644 index 00000000000..31bcc4a602d --- /dev/null +++ b/packages/ckeditor5-ui/tests/manual/textarea/textareaview.ts @@ -0,0 +1,105 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import { ButtonView, TextareaView } from '../../../src'; + +function createPlainTextarea() { + const textareaView = new TextareaView(); + + addToPlayground( 'Default textarea', textareaView ); +} + +function createPlainTextareaWithMoreRows() { + const textareaView = new TextareaView(); + + textareaView.minRows = 5; + textareaView.maxRows = 10; + + addToPlayground( 'min 5 rows, max 10 rows', textareaView ); +} + +function createPlainTextareaWithFixedRows() { + const textareaView = new TextareaView(); + + textareaView.minRows = 3; + textareaView.maxRows = 3; + + addToPlayground( '3 rows, fixed', textareaView ); +} + +function createPlainTextareaWithSingleRow() { + const textareaView = new TextareaView(); + + textareaView.minRows = 1; + textareaView.maxRows = 1; + + addToPlayground( '1 row, fixed', textareaView ); +} + +function createPlainTextareaWithVerticalResizeOnly() { + const textareaView = new TextareaView(); + + textareaView.resize = 'vertical'; + + addToPlayground( 'Default rows, manual v-resize only', textareaView ); +} + +function createPlainTextareaWithFixedSizeAndResizeBoth() { + const textareaView = new TextareaView(); + + textareaView.minRows = textareaView.maxRows = 3; + textareaView.resize = 'both'; + + addToPlayground( '3 fixed rows, resize: both', textareaView ); +} + +function addToPlayground( name, view ) { + view.render(); + + const setLargeTextButton = new ButtonView(); + const clearButton = new ButtonView(); + + view.value = 'Hello world!'; + setLargeTextButton.label = 'Set large text'; + setLargeTextButton.withText = true; + setLargeTextButton.render(); + setLargeTextButton.class = 'ck-button-save'; + + clearButton.label = 'Clear'; + clearButton.withText = true; + clearButton.render(); + clearButton.class = 'ck-button-cancel'; + + setLargeTextButton.on( 'execute', () => { + view.value = ''; + view.value = 'Life doesn\'t allow us to execute every single plan perfectly. This especially seems to be the case when you ' + + 'travel. You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes' + + ' up and you’re left with your improvising skills. You learn to adapt as you go.' + + 'Life doesn\'t allow us to execute every single plan perfectly. This especially seems to be the case when you ' + + 'travel. You plan it down to every minute with a big checklist. But when it comes to executing it, something always comes' + + ' up and you’re left with your improvising skills. You learn to adapt as you go.'; + } ); + + clearButton.on( 'execute', () => { + view.reset(); + } ); + + const container = document.createElement( 'div' ); + const heading = document.createElement( 'h2' ); + heading.textContent = name; + + container.appendChild( heading ); + container.appendChild( view.element! ); + container.appendChild( setLargeTextButton.element! ); + container.appendChild( clearButton.element! ); + document.querySelector( '.playground' )!.appendChild( container ); +} + +createPlainTextarea(); +createPlainTextareaWithMoreRows(); +createPlainTextareaWithFixedRows(); +createPlainTextareaWithSingleRow(); +createPlainTextareaWithVerticalResizeOnly(); +createPlainTextareaWithFixedSizeAndResizeBoth(); diff --git a/packages/ckeditor5-ui/tests/search/searchinfoview.js b/packages/ckeditor5-ui/tests/search/searchinfoview.js new file mode 100644 index 00000000000..6918fb53db2 --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/searchinfoview.js @@ -0,0 +1,64 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import SearchInfoView from '../../src/search/searchinfoview'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchInfoView', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new SearchInfoView(); + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-search__info' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + + it( 'sets #isVisible and creates a DOM binding', () => { + expect( view.isVisible ).to.be.false; + + view.isVisible = true; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; + } ); + + it( 'sets #primaryText and creates a DOM binding', () => { + expect( view.primaryText ).to.equal( '' ); + + view.primaryText = 'foo'; + + expect( view.element.innerHTML ).to.equal( 'foo' ); + } ); + + it( 'sets #secondaryText', () => { + expect( view.secondaryText ).to.equal( '' ); + + view.secondaryText = 'bar'; + + expect( view.element.innerHTML ).to.equal( 'bar' ); + } ); + } ); + + describe( 'focus()', () => { + it( 'should focus #element', () => { + const spy = sinon.spy( view.element, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/search/searchresultsview.js b/packages/ckeditor5-ui/tests/search/searchresultsview.js new file mode 100644 index 00000000000..c9ffd3f261a --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/searchresultsview.js @@ -0,0 +1,97 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import SearchResultsView from '../../src/search/searchresultsview'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { ButtonView, View, ViewCollection } from '../../src'; + +describe( 'SearchResultsView', () => { + let locale, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + locale = new Locale(); + + view = new SearchResultsView( locale ); + view.children.addMany( [ createNonFocusableView(), createFocusableView(), createFocusableView() ] ); + view.render(); + + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-search__results' ) ).to.be.true; + expect( view.element.getAttribute( 'tabIndex' ) ).to.equal( '-1' ); + } ); + + it( 'has a collection of #children', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + + view.children.add( new ButtonView() ); + + expect( view.element.firstChild ).to.equal( view.children.first.element ); + } ); + } ); + + describe( 'focus()', () => { + it( 'does nothing for empty panel', () => { + expect( () => view.focus() ).to.not.throw(); + } ); + + it( 'focuses first focusable view in #children', () => { + view.focus(); + + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); + + describe( 'focusFirst()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusFirst(); + + sinon.assert.calledOnce( view.children.get( 1 ).focus ); + } ); + } ); + + describe( 'focusLast()', () => { + it( 'focuses first focusable view in #children', () => { + view.focusLast(); + + sinon.assert.calledOnce( view.children.get( 2 ).focus ); + } ); + } ); + + function createFocusableView( name ) { + const view = createNonFocusableView(); + + view.name = name; + view.focus = () => view.element.focus(); + sinon.spy( view, 'focus' ); + + return view; + } + + function createNonFocusableView() { + const view = new View(); + + view.element = document.createElement( 'div' ); + view.element.textContent = 'foo'; + document.body.appendChild( view.element ); + + return view; + } +} ); diff --git a/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js b/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js new file mode 100644 index 00000000000..f1f1f2bc34d --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/text/searchtextqueryview.js @@ -0,0 +1,169 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import { Locale } from '@ckeditor/ckeditor5-utils'; +import { ButtonView, createLabeledInputText, IconView } from '@ckeditor/ckeditor5-ui'; +import SearchTextQueryView from '../../../src/search/text/searchtextqueryview'; +import { icons } from '@ckeditor/ckeditor5-core'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchTextQueryView', () => { + let locale, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + locale = new Locale(); + + view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test' + } ); + + view.render(); + } ); + + afterEach( () => { + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'sets view#locale', () => { + expect( view.locale ).to.equal( locale ); + } ); + + it( 'should have a label', () => { + expect( view.label ).to.equal( 'Test' ); + } ); + + describe( 'reset value button', () => { + it( 'should be created by default', () => { + const resetButtonView = view.fieldWrapperChildren.last; + + expect( resetButtonView ).to.equal( view.resetButtonView ); + expect( resetButtonView ).to.be.instanceOf( ButtonView ); + expect( resetButtonView.isVisible ).to.be.false; + expect( resetButtonView.tooltip ).to.be.true; + expect( resetButtonView.class ).to.equal( 'ck-search__reset' ); + expect( resetButtonView.label ).to.equal( 'Clear' ); + expect( resetButtonView.icon ).to.equal( icons.cancel ); + } ); + + it( 'should reset the search field value upon #execute', () => { + const resetSpy = testUtils.sinon.spy( view, 'reset' ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( resetSpy ); + } ); + + it( 'should focus the field view upon #execute', () => { + const focusSpy = testUtils.sinon.spy( view, 'focus' ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( focusSpy ); + } ); + + it( 'should get hidden upon #execute', () => { + view.resetButtonView.isVisible = true; + + view.resetButtonView.fire( 'execute' ); + + expect( view.resetButtonView.isVisible ).to.be.false; + } ); + + it( 'should fire the #reset event upon #execute', () => { + const spy = sinon.spy(); + + view.on( 'reset', spy ); + + view.resetButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be possible to hide using view\'s configuration', () => { + const view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test', + showResetButton: false + } ); + + expect( view.resetButtonView ).to.be.undefined; + expect( view.fieldWrapperChildren.last ).to.equal( view.labelView ); + + view.destroy(); + } ); + } ); + + describe( 'icon', () => { + it( 'should be added to the view by default', () => { + const iconView = view.fieldWrapperChildren.first; + + expect( view.iconView ).to.equal( iconView ); + expect( iconView ).to.equal( view.iconView ); + expect( iconView ).to.be.instanceOf( IconView ); + expect( iconView.content ).to.equal( icons.loupe ); + } ); + + it( 'should be possible to hide using view\'s configuration', () => { + const view = new SearchTextQueryView( locale, { + creator: createLabeledInputText, + label: 'Test', + showIcon: false + } ); + + expect( view.iconView ).to.be.undefined; + expect( view.fieldWrapperChildren.first ).to.equal( view.fieldView ); + + view.destroy(); + } ); + } ); + + describe( '#input event', () => { + it( 'should toggle visibility of the clear value button', () => { + view.fieldView.value = 'foo'; + view.fieldView.fire( 'input' ); + + expect( view.resetButtonView.isVisible ).to.be.true; + + view.fieldView.value = ''; + view.fieldView.fire( 'input' ); + + expect( view.resetButtonView.isVisible ).to.be.false; + } ); + } ); + } ); + + describe( 'reset()', () => { + it( 'should not fire the #reset event', () => { + const spy = sinon.spy(); + + view.on( 'reset', spy ); + + view.reset(); + + sinon.assert.notCalled( spy ); + } ); + + it( 'should clear the field view value in DOM', () => { + view.fieldView.element.value = 'foo'; + + view.reset(); + + expect( view.fieldView.element.value ).to.equal( '' ); + } ); + + it( 'should clear the field view value in InputView', () => { + view.fieldView.value = 'foo'; + + view.reset(); + + expect( view.fieldView.value ).to.equal( '' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/search/text/searchtextview.js b/packages/ckeditor5-ui/tests/search/text/searchtextview.js new file mode 100644 index 00000000000..0cb0388bfbe --- /dev/null +++ b/packages/ckeditor5-ui/tests/search/text/searchtextview.js @@ -0,0 +1,577 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* globals document */ + +import { FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils'; +import { + FocusCycler, + InputNumberView, + InputTextView, + LabeledFieldView, + ListView, + SearchInfoView, + SearchTextView, + View, + ViewCollection, + createLabeledInputNumber +} from '../../../src'; +import Locale from '@ckeditor/ckeditor5-utils/src/locale'; +import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; + +describe( 'SearchTextView', () => { + let view, filteredView; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + filteredView = new ListView(); + filteredView.filter = () => { + return { + resultsCount: 1, + totalItemsCount: 5 + }; + }; + + view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + filteredView.destroy(); + view.destroy(); + view.element.remove(); + } ); + + describe( 'constructor()', () => { + it( 'creates and element from template with CSS classes and attributes', () => { + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-search' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'supports extra CSS class in the config', () => { + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'foo' + }, + class: 'bar' + } ); + + view.render(); + + expect( view.element.classList.contains( 'bar' ) ).to.true; + + view.destroy(); + } ); + + it( 'creates an instance of FocusTracker', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'creates an instance of KeystrokeHandler', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'creates and instance of FocusCycler', () => { + expect( view.focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'assigns an instance of a view to #filteredView', () => { + expect( view.filteredView ).to.equal( filteredView ); + } ); + + it( 'creates a #resultsView as a container for the #filteredView', () => { + expect( view.resultsView ).to.be.instanceOf( View ); + + expect( view.resultsView.element.classList.contains( 'ck' ) ).to.true; + expect( view.resultsView.element.classList.contains( 'ck-search__results' ) ).to.true; + + expect( view.resultsView.children.first ).to.equal( view.infoView ); + expect( view.resultsView.children.last ).to.equal( filteredView ); + } ); + + it( 'sets #resultsCount', () => { + expect( view.resultsCount ).to.equal( 1 ); + } ); + + it( 'sets #totalItemsCount', () => { + expect( view.totalItemsCount ).to.equal( 5 ); + } ); + + it( 'should update #resultsCount and #totalItemsCount upon #search event', () => { + expect( view.resultsCount ).to.equal( 1 ); + expect( view.totalItemsCount ).to.equal( 5 ); + + view.fire( 'search', { resultsCount: 5, totalItemsCount: 10 } ); + + expect( view.resultsCount ).to.equal( 5 ); + expect( view.totalItemsCount ).to.equal( 10 ); + } ); + + it( 'should have #children view collection', () => { + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should have #focusableChildren view collection', () => { + expect( view.focusableChildren ).to.be.instanceOf( ViewCollection ); + } ); + + describe( '#queryView', () => { + it( 'gets created as labeled text view if not configured otherwise', () => { + expect( view.queryView ).to.be.instanceOf( LabeledFieldView ); + expect( view.queryView.fieldView ).to.be.instanceOf( InputTextView ); + expect( view.queryView.label ).to.equal( 'test label' ); + } ); + + it( 'gets created by a custom view creator configured by the user', () => { + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'foo', + creator: createLabeledInputNumber + }, + class: 'bar' + } ); + + view.render(); + + expect( view.queryView ).to.be.instanceOf( LabeledFieldView ); + expect( view.queryView.fieldView ).to.be.instanceOf( InputNumberView ); + + view.destroy(); + } ); + + it( 'shoud trigger #search() upon #input', () => { + const spy = sinon.spy( view, 'search' ); + + view.queryView.fieldView.fire( 'input' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should reset the entire view if fired #reset', () => { + const spy = sinon.spy( view, 'reset' ); + + view.queryView.fire( 'reset' ); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be bound to #isEnabled', () => { + expect( view.queryView.isEnabled ).to.be.true; + + view.isEnabled = false; + + expect( view.queryView.isEnabled ).to.be.false; + } ); + } ); + + describe( '#infoView', () => { + let view; + + beforeEach( () => { + filteredView.filter = () => { + return { + resultsCount: 5, + totalItemsCount: 5 + }; + }; + + view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + document.body.appendChild( view.element ); + } ); + + afterEach( () => { + view.destroy(); + view.element.remove(); + } ); + + describe( 'if not specified', () => { + it( 'is an instance of SearchInfoView if not specified in the config', () => { + expect( view.infoView ).to.be.instanceOf( SearchInfoView ); + expect( view.infoView.isVisible ).to.be.false; + } ); + + it( 'comes with a default behavior for no search results', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + view.search( 'will not be found' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'No results found' ); + expect( view.infoView.secondaryText ).to.equal( '' ); + + view.destroy(); + } ); + + it( 'comes with a default behavior for no searchable items', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'No searchable items' ); + expect( view.infoView.secondaryText ).to.equal( '' ); + + view.destroy(); + } ); + + it( 'allows customization of info texts', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + text: { + notFound: { + primary: 'foo', + secondary: 'bar' + }, + noSearchableItems: { + primary: 'baz', + secondary: 'qux' + } + } + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'baz' ); + expect( view.infoView.secondaryText ).to.equal( 'qux' ); + + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + view.search( 'test' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( 'foo' ); + expect( view.infoView.secondaryText ).to.equal( 'bar' ); + + view.destroy(); + } ); + + it( 'allows info texts specified as functions', () => { + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 0 + }; + }; + + const dynamicLabelText = ( query, resultsCount, totalItemsCount ) => + `"${ query }" ${ resultsCount } of ${ totalItemsCount }`; + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + text: { + notFound: { + primary: dynamicLabelText, + secondary: dynamicLabelText + }, + noSearchableItems: { + primary: dynamicLabelText, + secondary: dynamicLabelText + } + } + } + } ); + + view.render(); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( '"" 0 of 0' ); + expect( view.infoView.secondaryText ).to.equal( '"" 0 of 0' ); + + filteredView.filter = () => { + return { + resultsCount: 0, + totalItemsCount: 5 + }; + }; + + view.search( 'test' ); + + expect( view.infoView.isVisible ).to.be.true; + expect( view.infoView.primaryText ).to.equal( '"test" 0 of 5' ); + expect( view.infoView.secondaryText ).to.equal( '"test" 0 of 5' ); + + view.destroy(); + } ); + } ); + + it( 'accpets a view from the configuration', () => { + const customInfoView = new View(); + customInfoView.setTemplate( { + tag: 'div', + attributes: { + class: 'custom' + } + } ); + + const view = new SearchTextView( new Locale(), { + filteredView, + queryView: { + label: 'test label' + }, + infoView: { + instance: customInfoView + } + } ); + + view.render(); + + expect( view.infoView ).to.equal( customInfoView ); + expect( view.resultsView.children.first ).to.equal( customInfoView ); + expect( view.resultsView.children.last ).to.equal( filteredView ); + + view.destroy(); + } ); + } ); + } ); + + describe( 'render()', () => { + describe( 'focus tracking and cycling', () => { + it( 'should add #queryView and #resultsView to the #focusableChildren collection', () => { + expect( view.focusableChildren.map( view => view ) ).to.have.ordered.members( [ + view.queryView, view.resultsView + ] ); + } ); + + describe( 'activates keyboard navigation', () => { + it( 'makes "tab" focus the next focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the query input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.queryView.element; + + const spy = sinon.spy( view.resultsView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'makes "shift + tab" focus the previous focusable item', () => { + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the results are focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = filteredView.element; + + const spy = sinon.spy( view.resultsView, 'focus' ); + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'should allow adding extra views to the focus cycling logic', () => { + const anotherFocusableView = new View(); + + anotherFocusableView.setTemplate( { + tag: 'div', + attributes: { + tabindex: -1 + } + } ); + + anotherFocusableView.focus = sinon.spy(); + + anotherFocusableView.render(); + + view.focusTracker.add( anotherFocusableView ); + view.focusableChildren.add( anotherFocusableView ); + view.element.appendChild( anotherFocusableView.element ); + + // Mock the query input is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.queryView.element; + + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + view.keystrokes.press( keyEvtData ); + sinon.assert.calledOnce( anotherFocusableView.focus ); + } ); + } ); + + it( 'intercepts the arrow* events and overrides the default 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( 'should add #queryView and #resultsView to the #children view collection', () => { + expect( view.children.map( child => child ) ).to.deep.equal( [ view.queryView, view.resultsView ] ); + + expect( view.element.firstChild ).to.equal( view.queryView.element ); + expect( view.element.lastChild ).to.equal( view.resultsView.element ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the #queryView', () => { + const spy = sinon.spy( view.queryView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); + + describe( 'reset()', () => { + it( 'resets the #queryView', () => { + const spy = sinon.spy( view.queryView, 'reset' ); + + view.reset(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'resets the search results', () => { + const spy = sinon.spy( view, 'search' ); + + view.reset(); + + sinon.assert.calledOnceWithExactly( spy, '' ); + } ); + } ); + + describe( 'search()', () => { + it( 'should escape the query when creating a RegExp to avoid mismatches', () => { + const spy = sinon.spy( filteredView, 'filter' ); + + view.search( 'foo[ar]' ); + sinon.assert.calledOnceWithExactly( spy, /foo\[ar\]/gi ); + + view.search( 'foo/bar' ); + sinon.assert.calledWithExactly( spy.secondCall, /foo\/bar/gi ); + } ); + + it( 'should filter the #filteredView', () => { + const spy = sinon.spy( filteredView, 'filter' ); + + view.search( 'foo' ); + + sinon.assert.calledOnceWithExactly( spy, /foo/gi ); + } ); + + it( 'should fire the #search event with the query and search stats', done => { + filteredView.filter = () => { + return { + resultsCount: 1, + totalItemsCount: 10 + }; + }; + + view.on( 'search', ( evt, data ) => { + expect( data ).to.deep.equal( { + query: 'foo', + resultsCount: 1, + totalItemsCount: 10 + } ); + + done(); + } ); + + view.search( 'foo' ); + } ); + } ); +} ); diff --git a/packages/ckeditor5-ui/tests/spinner/spinner.js b/packages/ckeditor5-ui/tests/spinner/spinner.js new file mode 100644 index 00000000000..df991060ff7 --- /dev/null +++ b/packages/ckeditor5-ui/tests/spinner/spinner.js @@ -0,0 +1,44 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +import SpinnerView from '../../src/spinner/spinnerview'; + +describe( 'SpinnerView', () => { + let view; + + beforeEach( () => { + view = new SpinnerView(); + view.render(); + } ); + + describe( 'constructor()', () => { + it( 'sets #isVisible', () => { + expect( view.isVisible ).to.equal( false ); + } ); + + it( 'creates element from template', () => { + expect( view.element.tagName ).to.equal( 'SPAN' ); + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-spinner-container' ) ).to.be.true; + + expect( view.element.children[ 0 ].tagName ).to.equal( 'SPAN' ); + expect( view.element.children[ 0 ].classList.contains( 'ck' ) ).to.be.true; + expect( view.element.children[ 0 ].classList.contains( 'ck-spinner' ) ).to.be.true; + } ); + } ); + + describe( 'bindings', () => { + it( 'should react to changes in view#isVisible', () => { + view.isVisible = true; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.false; + + view.isVisible = false; + + expect( view.element.classList.contains( 'ck-hidden' ) ).to.be.true; + } ); + } ); +} ); + diff --git a/packages/ckeditor5-ui/tests/textarea/textareaview.js b/packages/ckeditor5-ui/tests/textarea/textareaview.js new file mode 100644 index 00000000000..8340f90a6ee --- /dev/null +++ b/packages/ckeditor5-ui/tests/textarea/textareaview.js @@ -0,0 +1,258 @@ +/** + * @license Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* global document */ + +import { global } from '@ckeditor/ckeditor5-utils'; +import TextareaView from '../../src/textarea/textareaview'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; +import { expectToThrowCKEditorError } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; + +describe( 'TextareaView', () => { + let wrapper, view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + // The reset wrapper is needed for proper line height calculation. + wrapper = document.createElement( 'div' ); + wrapper.classList.add( 'ck', 'ck-reset_all' ); + + view = new TextareaView(); + view.render(); + wrapper.appendChild( view.element ); + document.body.appendChild( wrapper ); + } ); + + afterEach( () => { + view.destroy(); + wrapper.remove(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.tagName ).to.equal( 'TEXTAREA' ); + expect( view.element.getAttribute( 'type' ) ).to.be.null; + expect( view.element.classList.contains( 'ck' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-input' ) ).to.be.true; + expect( view.element.classList.contains( 'ck-textarea' ) ).to.be.true; + } ); + + it( 'should have default resize attribute value', () => { + expect( view.element.style.resize ).to.equal( 'none' ); + } ); + + it( 'should throw if #minHeight is greater than #maxHeight', () => { + view.minRows = 2; + view.maxRows = 3; + view.minRows = view.maxRows; + + expectToThrowCKEditorError( + () => { view.minRows = 4; }, + 'ui-textarea-view-min-rows-greater-than-max-rows', + { + view, + minRows: 4, + maxRows: 3 + } + ); + + expectToThrowCKEditorError( + () => { view.minRows = 5; }, + 'ui-textarea-view-min-rows-greater-than-max-rows', + { + view, + minRows: 5, + maxRows: 3 + } + ); + } ); + } ); + + describe( 'reset()', () => { + it( 'should reset the #value of the view', () => { + view.value = 'foo'; + + view.reset(); + + expect( view.value ).to.equal( '' ); + } ); + + it( 'should reset the value of the DOM #element', () => { + view.element.value = 'foo'; + + view.reset(); + + expect( view.element.value ).to.equal( '' ); + } ); + + it( 'should update the size of the view', () => { + view.element.value = '1'; + view.fire( 'input' ); + + const initialHeight = view.element.style.height; + const initialScrollTop = view.element.scrollTop; + + view.element.value = '1\n2\n3\n4\n5\n6'; + view.fire( 'input' ); + expect( view.element.style.height ).to.not.equal( initialHeight ); + expect( view.element.scrollTop ).to.not.equal( initialScrollTop ); + + view.reset(); + expect( view.element.style.height ).to.equal( initialHeight ); + expect( view.element.scrollTop ).to.equal( initialScrollTop ); + } ); + } ); + + describe( 'render()', () => { + it( 'should resize the view on the #input event and scroll to the end', async () => { + const initialHeight = view.element.style.height; + const initialScrollTop = view.element.scrollTop; + + view.element.value = '1\n2\n3\n4\n5\n6'; + + expect( view.element.style.height ).to.equal( initialHeight ); + expect( view.element.scrollTop ).to.equal( initialScrollTop ); + + view.fire( 'input' ); + + expect( view.element.style.height ).to.not.equal( initialHeight ); + expect( view.element.scrollTop ).to.not.equal( initialScrollTop ); + } ); + + it( 'should resize the view on the #value change using requestAnimationFrame to let the browser update the UI', async () => { + const initialHeight = view.element.style.height; + + view.value = 'foo\nbar\nbaz\nqux'; + + expect( view.element.style.height ).to.equal( initialHeight ); + + await requestAnimationFrame(); + expect( view.element.style.height ).to.not.equal( initialHeight ); + } ); + + describe( 'dynamic resizing', () => { + it( 'should respect #minRows and #maxRows', async () => { + // One row, it's less than default #minRows. + view.value = '1'; + await requestAnimationFrame(); + const oneRowHeight = parseFloat( view.element.style.height ); + + // Two rows (default). + view.value = '1\n2'; + await requestAnimationFrame(); + const twoRowsHeight = parseFloat( view.element.style.height ); + expect( twoRowsHeight ).to.equal( oneRowHeight ); + + // Three rows (more then default #minRows), resize again. + view.value = '1\n2\n3'; + await requestAnimationFrame(); + const threeRowsHeight = parseFloat( view.element.style.height ); + expect( threeRowsHeight ).to.be.greaterThan( twoRowsHeight ); + + // Four rows. + view.value = '1\n2\n3\n4'; + await requestAnimationFrame(); + const fourRowsHeight = parseFloat( view.element.style.height ); + expect( fourRowsHeight ).to.be.greaterThan( threeRowsHeight ); + + // Five rows (default #maxRows), this will be the max height. + view.value = '1\n2\n3\n4\n5'; + await requestAnimationFrame(); + const maxHeight = parseFloat( view.element.style.height ); + expect( maxHeight ).to.be.greaterThan( fourRowsHeight ); + + // Six rows (more than #maxRows), the view is no longer growing. + view.value = '1\n2\n3\n4\n5\n6'; + await requestAnimationFrame(); + expect( parseFloat( view.element.style.height ) ).to.equal( maxHeight ); + + // Going back to #minRows + view.value = '1'; + await requestAnimationFrame(); + expect( parseFloat( view.element.style.height ) ).to.equal( twoRowsHeight ); + } ); + } ); + + describe( '#update event', () => { + it( 'should get fired on the user #input', () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.element.value = '1\n2\n3\n4\n5\n6'; + + view.fire( 'input' ); + sinon.assert.calledOnce( spy ); + + view.fire( 'input' ); + + // The event gets fired whether the view is changing dimensions or not. + sinon.assert.calledTwice( spy ); + } ); + + it( 'should get fired on #value change', async () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.value = '1\n2\n3\n4\n5\n6'; + + await requestAnimationFrame(); + + sinon.assert.calledOnce( spy ); + } ); + + it( 'should be fired upon reset()', async () => { + const spy = sinon.spy(); + + view.on( 'update', spy ); + + view.value = '1\n2\n3\n4\n5\n6'; + + await requestAnimationFrame(); + + sinon.assert.calledOnce( spy ); + + view.reset(); + + sinon.assert.calledTwice( spy ); + } ); + } ); + } ); + + describe( 'DOM bindings', () => { + beforeEach( () => { + view.value = 'foo'; + view.id = 'bar'; + } ); + + describe( 'rows attribute', () => { + it( 'should react on view#minRows', () => { + expect( view.element.getAttribute( 'rows' ) ).to.equal( '2' ); + + view.minRows = 5; + + expect( view.element.getAttribute( 'rows' ) ).to.equal( '5' ); + } ); + } ); + + describe( 'resize attribute', () => { + it( 'should react on view#reisze', () => { + expect( view.element.style.resize ).to.equal( 'none' ); + + view.resize = 'vertical'; + + expect( view.element.style.resize ).to.equal( 'vertical' ); + } ); + } ); + } ); + + function requestAnimationFrame() { + return new Promise( res => { + global.window.requestAnimationFrame( res ); + } ); + } +} ); diff --git a/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css b/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css new file mode 100644 index 00000000000..561f5892265 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/autocomplete/autocomplete.css @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck.ck-autocomplete { + position: relative; + + & > .ck-search__results { + position: absolute; + z-index: var(--ck-z-modal); + + &.ck-search__results_n { + bottom: 100%; + } + + &.ck-search__results_s { + top: 100%; + bottom: auto; + } + } +} diff --git a/packages/ckeditor5-ui/theme/components/formheader/formheader.css b/packages/ckeditor5-ui/theme/components/formheader/formheader.css index 930f3d11681..bb85138701e 100644 --- a/packages/ckeditor5-ui/theme/components/formheader/formheader.css +++ b/packages/ckeditor5-ui/theme/components/formheader/formheader.css @@ -9,4 +9,12 @@ flex-wrap: nowrap; align-items: center; justify-content: space-between; + + & .ck-icon { + margin-right: var(--ck-spacing-medium); + } + + & h2.ck-form__header__label { + flex-grow: 1; + } } diff --git a/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css b/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css new file mode 100644 index 00000000000..5462bcd81fb --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/highlightedtext/highlightedtext.css @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +.ck.ck-highlighted-text mark { + background: var(--ck-color-highlight-background); + vertical-align: initial; + font-weight: inherit; + line-height: inherit; + font-size: inherit; +} diff --git a/packages/ckeditor5-ui/theme/components/search/search.css b/packages/ckeditor5-ui/theme/components/search/search.css new file mode 100644 index 00000000000..28c4ec1c90e --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/search/search.css @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +@import "@ckeditor/ckeditor5-ui/theme/mixins/_dir.css"; + +.ck.ck-search { + & > .ck-labeled-field-view { + & > .ck-labeled-field-view__input-wrapper > .ck-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + + @mixin ck-dir ltr { + left: var(--ck-spacing-medium); + } + + @mixin ck-dir rtl { + right: var(--ck-spacing-medium); + } + } + + & .ck-search__reset { + position: absolute; + top: 50%; + transform: translateY(-50%); + } + } + + & > .ck-search__results { + & > .ck-search__info { + & > span:first-child { + display: block; + } + + /* Hide the filtered view when nothing was found */ + &:not(.ck-hidden) ~ * { + display: none; + } + } + } +} diff --git a/packages/ckeditor5-ui/theme/components/spinner/spinner.css b/packages/ckeditor5-ui/theme/components/spinner/spinner.css new file mode 100644 index 00000000000..16969cc9742 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/spinner/spinner.css @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +:root { + --ck-toolbar-spinner-size: 18px; +} + +.ck.ck-spinner-container { + display: block; + position: relative; +} + +.ck.ck-spinner { + position: absolute; + top: 50%; + left: 0; + right: 0; + margin: 0 auto; + transform: translateY(-50%); + z-index: 1; +} diff --git a/packages/ckeditor5-ui/theme/components/textarea/textarea.css b/packages/ckeditor5-ui/theme/components/textarea/textarea.css new file mode 100644 index 00000000000..563b03d7a66 --- /dev/null +++ b/packages/ckeditor5-ui/theme/components/textarea/textarea.css @@ -0,0 +1,10 @@ +/* + * Copyright (c) 2003-2023, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/* + * Note: This file should contain the wireframe styles only. But since there are no such styles, + * it acts as a message to the builder telling that it should look for the corresponding styles + * **in the theme** when compiling the editor. + */ diff --git a/packages/ckeditor5-utils/src/index.ts b/packages/ckeditor5-utils/src/index.ts index 9f2c77cfcb6..e9d021373fb 100644 --- a/packages/ckeditor5-utils/src/index.ts +++ b/packages/ckeditor5-utils/src/index.ts @@ -52,6 +52,7 @@ export { default as findClosestScrollableAncestor } from './dom/findclosestscrol export { default as global } from './dom/global'; export { default as getAncestors } from './dom/getancestors'; export { default as getDataFromElement } from './dom/getdatafromelement'; +export { default as getBorderWidths } from './dom/getborderwidths'; export { default as isText } from './dom/istext'; export { default as Rect, type RectSource } from './dom/rect'; export { default as ResizeObserver } from './dom/resizeobserver';