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 `