diff --git a/packages/ckeditor5-bookmark/src/bookmarkediting.ts b/packages/ckeditor5-bookmark/src/bookmarkediting.ts index 2b0d5609b87..a4088e4cb76 100644 --- a/packages/ckeditor5-bookmark/src/bookmarkediting.ts +++ b/packages/ckeditor5-bookmark/src/bookmarkediting.ts @@ -7,7 +7,7 @@ * @module bookmark/bookmarkediting */ -import { type Editor, Plugin, icons } from 'ckeditor5/src/core.js'; +import { Plugin, icons, type Editor } from 'ckeditor5/src/core.js'; import { toWidget } from 'ckeditor5/src/widget.js'; import { IconView } from 'ckeditor5/src/ui.js'; import type { EventInfo } from 'ckeditor5/src/utils.js'; @@ -81,6 +81,13 @@ export default class BookmarkEditing extends Plugin { return null; } + /** + * Returns all unique bookmark names existing in the content. + */ + public getAllBookmarkNames(): Set { + return new Set( this._bookmarkElements.values() ); + } + /** * Defines the schema for the bookmark feature. */ diff --git a/packages/ckeditor5-bookmark/tests/bookmarkediting.js b/packages/ckeditor5-bookmark/tests/bookmarkediting.js index df2f89e9539..db9d74047e9 100644 --- a/packages/ckeditor5-bookmark/tests/bookmarkediting.js +++ b/packages/ckeditor5-bookmark/tests/bookmarkediting.js @@ -1156,6 +1156,45 @@ describe( 'BookmarkEditing', () => { } ); } ); + describe( 'getAllBookmarkNames', () => { + it( 'should return all bookmark names', () => { + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + editor.setData( + '

' + + '' + + '

' + + '

' + + '' + + '

' + + '

' + + '' + + '

' + ); + + expect( bookmarkEditing.getAllBookmarkNames() ).is.instanceof( Set ); + expect( bookmarkEditing.getAllBookmarkNames() ).is.deep.equal( new Set( [ 'foo', 'bar', 'baz' ] ) ); + } ); + + it( 'should return all unique bookmark names', () => { + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + + editor.setData( + '

' + + '' + + '

' + + '

' + + '' + + '

' + + '

' + + '' + + '

' + ); + + expect( bookmarkEditing.getAllBookmarkNames() ).is.deep.equal( new Set( [ 'foo', 'bar' ] ) ); + } ); + } ); + describe( 'clipboard', () => { let clipboardPlugin, viewDocument; diff --git a/packages/ckeditor5-link/lang/contexts.json b/packages/ckeditor5-link/lang/contexts.json index ff2b9f3d89c..eadfa574afb 100644 --- a/packages/ckeditor5-link/lang/contexts.json +++ b/packages/ckeditor5-link/lang/contexts.json @@ -13,6 +13,7 @@ "Create link": "Keystroke description for assistive technologies: keystroke for creating a link.", "Move out of a link": "Keystroke description for assistive technologies: keystroke for moving out of a link.", "Bookmarks": "Title for a feature displaying a list of bookmarks.", + "No bookmarks available.": "A message displayed instead of a list of bookmarks if it is empty.", "Advanced": "Title for a feature displaying advanced link settings.", "Displayed text": "The label of the input field for the displayed text of the link." } diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts index 959596313e1..90c197bcfd2 100644 --- a/packages/ckeditor5-link/src/linkui.ts +++ b/packages/ckeditor5-link/src/linkui.ts @@ -7,7 +7,7 @@ * @module link/linkui */ -import { Plugin, type Editor } from 'ckeditor5/src/core.js'; +import { Plugin, icons, type Editor } from 'ckeditor5/src/core.js'; import { ClickObserver, type ViewAttributeElement, @@ -24,10 +24,12 @@ import { MenuBarMenuListItemButtonView, type ViewWithCssTransitionDisabler } from 'ckeditor5/src/ui.js'; + import type { PositionOptions } from 'ckeditor5/src/utils.js'; import { isWidget } from 'ckeditor5/src/widget.js'; import LinkFormView, { type LinkFormValidatorCallback } from './ui/linkformview.js'; +import LinkBookmarksView from './ui/linkbookmarksview.js'; import LinkAdvancedView from './ui/linkadvancedview.js'; import LinkButtonView from './ui/linkbuttonview.js'; import LinkActionsView from './ui/linkactionsview.js'; @@ -61,6 +63,11 @@ export default class LinkUI extends Plugin { */ public formView: LinkFormView & ViewWithCssTransitionDisabler | null = null; + /** + * The view displaying bookmarks list. + */ + public bookmarksView: LinkBookmarksView | null = null; + /** * The form view displaying advanced link settings. */ @@ -170,6 +177,10 @@ export default class LinkUI extends Plugin { if ( this.actionsView ) { this.actionsView.destroy(); } + + if ( this.bookmarksView ) { + this.bookmarksView.destroy(); + } } /** @@ -180,6 +191,11 @@ export default class LinkUI extends Plugin { this.formView = this._createFormView(); this.advancedView = this._createAdvancedView(); + if ( this.editor.plugins.has( 'BookmarkEditing' ) ) { + this.bookmarksView = this._createBookmarksView(); + this.formView.listChildren.add( this._createBookmarksButton() ); + } + // Attach lifecycle actions to the the balloon. this._enableUserBalloonInteractions(); } @@ -238,10 +254,6 @@ export default class LinkUI extends Plugin { const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, getFormValidators( editor ) ); - if ( editor.plugins.has( 'BookmarkEditing' ) ) { - formView.listChildren.add( this._createBookmarksButton() ); - } - formView.urlInputView.fieldView.bind( 'value' ).to( linkCommand, 'value' ); // TODO: Bind to the "Displayed text" input @@ -299,6 +311,63 @@ export default class LinkUI extends Plugin { return formView; } + /** + * Creates a sorted array of buttons with bookmark names. + */ + private _createBookmarksListView(): Array { + const editor = this.editor; + const bookmarkEditing = editor.plugins.get( 'BookmarkEditing' ); + const bookmarksNames = Array.from( bookmarkEditing.getAllBookmarkNames() ); + + bookmarksNames.sort( ( a, b ) => a.localeCompare( b ) ); + + return bookmarksNames.map( bookmarkName => { + const buttonView = new ButtonView(); + + buttonView.set( { + label: bookmarkName, + tooltip: false, + icon: icons.bookmark, + withText: true + } ); + + buttonView.on( 'execute', () => { + this.formView!.urlInputView.fieldView.value = '#' + bookmarkName; + + // Set focus to the editing view to prevent from losing it while current view is removed. + editor.editing.view.focus(); + + this._removeBookmarksView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return buttonView; + } ); + } + + /** + * Creates a view for bookmarks. + */ + private _createBookmarksView(): LinkBookmarksView { + const editor = this.editor; + const view = new LinkBookmarksView( editor.locale ); + + // Hide the panel after clicking the "Cancel" button. + this.listenTo( view, 'cancel', () => { + // Set focus to the editing view to prevent from losing it while current view is removed. + editor.editing.view.focus(); + + this._removeBookmarksView(); + + // Set the focus to the URL input field. + this.formView!.focus(); + } ); + + return view; + } + /** * Creates the {@link module:link/ui/linkadvancedview~LinkAdvancedView} instance. */ @@ -401,6 +470,10 @@ export default class LinkUI extends Plugin { label: t( 'Bookmarks' ) } ); + this.listenTo( bookmarksButton, 'execute', () => { + this._addBookmarksView(); + } ); + return bookmarksButton; } @@ -554,6 +627,24 @@ export default class LinkUI extends Plugin { this.formView!.enableCssTransitions(); } + /** + * Adds the {@link #bookmarksView} to the {@link #_balloon}. + */ + private _addBookmarksView(): void { + // Clear the collection of bookmarks. + this.bookmarksView!.listChildren.clear(); + + // Add bookmarks to the collection. + this.bookmarksView!.listChildren.addMany( this._createBookmarksListView() ); + + this._balloon.add( { + view: this.bookmarksView!, + position: this._getBalloonPositionData() + } ); + + this.bookmarksView!.focus(); + } + /** * Closes the form view. Decides whether the balloon should be hidden completely or if the action view should be shown. This is * decided upon the link command value (which has a value if the document selection is in the link). @@ -585,6 +676,15 @@ export default class LinkUI extends Plugin { } } + /** + * Removes the {@link #bookmarksView} from the {@link #_balloon}. + */ + private _removeBookmarksView(): void { + if ( this._areBookmarksInPanel ) { + this._balloon.remove( this.bookmarksView! ); + } + } + /** * Removes the {@link #formView} from the {@link #_balloon}. */ @@ -674,7 +774,10 @@ export default class LinkUI extends Plugin { // TODO: Remove dynamically registered views - // Remove the advanced form view first because it's on top of the stack. + // If the bookmarks view is visible, remove it because it can be on top of the stack. + this._removeBookmarksView(); + + // If the advanced form view is visible, remove it because it can be on top of the stack. this._removeAdvancedView(); // Then remove the form view because it's beneath the advanced form. @@ -749,6 +852,13 @@ export default class LinkUI extends Plugin { return !!this.advancedView && this._balloon.hasView( this.advancedView ); } + /** + * Returns `true` when {@link #bookmarksView} is in the {@link #_balloon}. + */ + private get _areBookmarksInPanel(): boolean { + return !!this.bookmarksView && this._balloon.hasView( this.bookmarksView ); + } + /** * Returns `true` when {@link #formView} is in the {@link #_balloon}. */ @@ -788,18 +898,27 @@ export default class LinkUI extends Plugin { } /** - * Returns `true` when {@link #advancedView}, {@link #actionsView} or {@link #formView} is in the {@link #_balloon}. + * Returns `true` when {@link #bookmarksView} is in the {@link #_balloon} and it is + * currently visible. + */ + private get _areBookmarksVisible(): boolean { + return !!this.bookmarksView && this._balloon.visibleView === this.bookmarksView; + } + + /** + * Returns `true` when {@link #advancedView}, {@link #actionsView}, {@link #bookmarksView} + * or {@link #formView} is in the {@link #_balloon}. */ private get _isUIInPanel(): boolean { - return this._isAdvancedInPanel || this._isFormInPanel || this._areActionsInPanel; + return this._isAdvancedInPanel || this._areBookmarksInPanel || this._isFormInPanel || this._areActionsInPanel; } /** - * Returns `true` when {@link #advancedView}, {@link #actionsView} or {@link #formView} is in the {@link #_balloon} - * and it is currently visible. + * Returns `true` when {@link #advancedView}, {@link #bookmarksView}, {@link #actionsView} + * or {@link #formView} is in the {@link #_balloon} and it is currently visible. */ private get _isUIVisible(): boolean { - return this._isAdvancedVisible || this._isFormVisible || this._areActionsVisible; + return this._isAdvancedVisible || this._areBookmarksVisible || this._isFormVisible || this._areActionsVisible; } /** diff --git a/packages/ckeditor5-link/src/ui/linkbookmarksview.ts b/packages/ckeditor5-link/src/ui/linkbookmarksview.ts new file mode 100644 index 00000000000..65e0985bace --- /dev/null +++ b/packages/ckeditor5-link/src/ui/linkbookmarksview.ts @@ -0,0 +1,282 @@ +/** + * @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved. + * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license + */ + +/** + * @module link/ui/linkbookmarksview + */ + +import { + ButtonView, + FocusCycler, + FormHeaderView, + View, + ListView, + ListItemView, + ViewCollection, + type FocusableView +} from 'ckeditor5/src/ui.js'; + +import { + FocusTracker, + KeystrokeHandler, + type Locale +} from 'ckeditor5/src/utils.js'; + +import { icons } from 'ckeditor5/src/core.js'; + +/** + * The link bookmarks list view. + */ +export default class LinkBookmarksView extends View { + /** + * Tracks information about the list of bookmarks. + * + * @observable + */ + declare public hasItems: boolean; + + /** + * Tracks information about DOM focus in the form. + */ + public readonly focusTracker = new FocusTracker(); + + /** + * An instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}. + */ + public readonly keystrokes = new KeystrokeHandler(); + + /** + * The Back button view displayed in the header. + */ + public backButtonView: ButtonView; + + /** + * The List view of bookmarks buttons. + */ + public listView: ListView; + + /** + * The collection of child views, which is bind with the `listView`. + */ + public readonly listChildren: ViewCollection; + + /** + * The view displayed when the list is empty. + */ + public emptyListInformation: View; + + /** + * A collection of child views. + */ + public children: ViewCollection; + + /** + * A collection of views that can be focused in the form. + */ + private readonly _focusables = new ViewCollection(); + + /** + * Helps cycling over {@link #_focusables} in the form. + */ + private readonly _focusCycler: FocusCycler; + + /** + * Creates an instance of the {@link module:link/ui/linkbookmarksview~LinkBookmarksView} class. + * + * Also see {@link #render}. + * + * @param locale The localization services instance. + */ + constructor( locale: Locale ) { + super( locale ); + + this.listChildren = this.createCollection(); + + this.backButtonView = this._createBackButton(); + this.listView = this._createListView(); + this.emptyListInformation = this._createEmptyBookmarksListItemView(); + + this.children = this.createCollection( [ + this._createHeaderView(), + this.emptyListInformation + ] ); + + this.set( 'hasItems', false ); + + this.listenTo( this.listChildren, 'change', () => { + this.hasItems = this.listChildren.length > 0; + } ); + + this.on( 'change:hasItems', ( evt, propName, hasItems ) => { + if ( hasItems ) { + this.children.remove( this.emptyListInformation ); + this.children.add( this.listView ); + } else { + this.children.remove( this.listView ); + this.children.add( this.emptyListInformation ); + } + } ); + + // Close the panel on esc key press when the **form has focus**. + this.keystrokes.set( 'Esc', ( data, cancel ) => { + this.fire( 'cancel' ); + cancel(); + } ); + + this._focusCycler = new FocusCycler( { + focusables: this._focusables, + focusTracker: this.focusTracker, + keystrokeHandler: this.keystrokes, + actions: { + // Navigate form fields backwards using the Shift + Tab keystroke. + focusPrevious: 'shift + tab', + + // Navigate form fields forwards using the Tab key. + focusNext: 'tab' + } + } ); + + this.setTemplate( { + tag: 'div', + + attributes: { + class: [ 'ck', 'ck-link__panel', 'ck-link__bookmarks' ], + + // https://github.com/ckeditor/ckeditor5-link/issues/90 + tabindex: '-1' + }, + + children: this.children + } ); + } + + /** + * @inheritDoc + */ + public override render(): void { + super.render(); + + const childViews = [ + this.listView, + this.backButtonView + ]; + + childViews.forEach( v => { + // Register the view as focusable. + this._focusables.add( v ); + + // Register the view in the focus tracker. + this.focusTracker.add( v.element! ); + } ); + + // Start listening for the keystrokes coming from #element. + this.keystrokes.listenTo( this.element! ); + } + + /** + * @inheritDoc + */ + public override destroy(): void { + super.destroy(); + + this.focusTracker.destroy(); + this.keystrokes.destroy(); + } + + /** + * Focuses the fist {@link #_focusables} in the form. + */ + public focus(): void { + this._focusCycler.focusFirst(); + } + + /** + * Creates a view for the list at the bottom. + */ + private _createListView(): ListView { + const listView = new ListView( this.locale ); + + listView.extendTemplate( { + attributes: { + class: [ + 'ck-list__bookmark-items' + ] + } + } ); + + listView.items.bindTo( this.listChildren ).using( button => { + const listItemView = new ListItemView( this.locale ); + + listItemView.children.add( button ); + + return listItemView; + } ); + + return listView; + } + + /** + * Creates a back button view that cancels the form. + */ + private _createBackButton(): ButtonView { + const t = this.locale!.t; + const backButton = new ButtonView( this.locale ); + + backButton.set( { + label: t( 'Cancel' ), + icon: icons.previousArrow, + tooltip: true + } ); + + backButton.delegate( 'execute' ).to( this, 'cancel' ); + + return backButton; + } + + /** + * Creates a header view for the form. + */ + private _createHeaderView(): FormHeaderView { + const t = this.locale!.t; + + const header = new FormHeaderView( this.locale, { + label: t( 'Bookmarks' ) + } ); + + header.children.add( this.backButtonView, 0 ); + + return header; + } + + /** + * Creates an info view for an empty list. + */ + private _createEmptyBookmarksListItemView(): View { + const t = this.locale!.t; + const view = new View( this.locale ); + + view.setTemplate( { + tag: 'p', + attributes: { + class: [ 'ck ck-link__empty-list-info' ] + }, + children: [ + t( 'No bookmarks available.' ) + ] + } ); + + return view; + } +} + +/** + * Fired when the bookmarks view is canceled, for example with a click on {@link ~LinkBookmarksView#backButtonView}. + * + * @eventName ~LinkBookmarksView#cancel + */ +export type CancelEvent = { + name: 'cancel'; + args: []; +}; diff --git a/packages/ckeditor5-link/src/ui/linkformview.ts b/packages/ckeditor5-link/src/ui/linkformview.ts index 20e07b18dd5..08e0b44002c 100644 --- a/packages/ckeditor5-link/src/ui/linkformview.ts +++ b/packages/ckeditor5-link/src/ui/linkformview.ts @@ -34,9 +34,7 @@ import '@ckeditor/ckeditor5-ui/theme/components/responsive-form/responsiveform.c import '../../theme/linkform.css'; /** - * The link form view controller class. - * - * See {@link module:link/ui/linkformview~LinkFormView}. + * The link form view. */ export default class LinkFormView extends View { /** diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index f071055bf3c..43313a79602 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -23,12 +23,14 @@ import ContextualBalloon from '@ckeditor/ckeditor5-ui/src/panel/balloon/contextu import ButtonView from '@ckeditor/ckeditor5-ui/src/button/buttonview.js'; import View from '@ckeditor/ckeditor5-ui/src/view.js'; import { toWidget } from '@ckeditor/ckeditor5-widget'; +import { icons } from '@ckeditor/ckeditor5-core'; import LinkEditing from '../src/linkediting.js'; import LinkUI from '../src/linkui.js'; import LinkFormView from '../src/ui/linkformview.js'; import LinkButtonView from '../src/ui/linkbuttonview.js'; import LinkActionsView from '../src/ui/linkactionsview.js'; +import LinkBookmarksView from '../src/ui/linkbookmarksview.js'; import LinkAdvancedView from '../src/ui/linkadvancedview.js'; import { MenuBarMenuListItemButtonView } from '@ckeditor/ckeditor5-ui'; @@ -1016,6 +1018,22 @@ describe( 'LinkUI', () => { } ); } ); + describe( '_createBookmarksView()', () => { + beforeEach( () => { + editor.editing.view.document.isFocused = true; + } ); + + describe( 'when Bookmark plugin is not loaded', () => { + it( 'should not create #advancedView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.bookmarksView ).to.be.equal( null ); + } ); + } ); + } ); + describe( '_hideUI()', () => { beforeEach( () => { linkUIFeature._showUI(); @@ -2282,7 +2300,9 @@ describe( 'LinkUI', () => { } ); describe( 'LinkUI with Bookmark', () => { - let editor, linkUIFeature, balloon, editorElement; + const bookmarkIcon = icons.bookmark; + + let editor, linkUIFeature, balloon, editorElement, bookmarksView; testUtils.createSinonSandbox(); @@ -2328,5 +2348,136 @@ describe( 'LinkUI with Bookmark', () => { expect( linkUIFeature.formView ).to.be.instanceOf( LinkFormView ); expect( button ).to.be.instanceOf( LinkButtonView ); + + expect( linkUIFeature._areBookmarksVisible ).to.be.false; + + button.fire( 'execute' ); + + expect( linkUIFeature._areBookmarksVisible ).to.be.true; + } ); + + describe( '_createBookmarksView()', () => { + it( 'should create #bookmarksView', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + expect( linkUIFeature.bookmarksView ).to.be.instanceOf( LinkBookmarksView ); + } ); + } ); + + describe( '_createBookmarksListView()', () => { + describe( 'with bookmarks data', () => { + let bookmarksView; + + beforeEach( () => { + setModelData( editor.model, + 'f[o]o' + + '' + + '' + + '' + + '' + ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + bookmarksView = linkUIFeature.bookmarksView; + } ); + + it( 'should create a sorted list of bookmarks buttons', () => { + expect( bookmarksView.listChildren.length ).to.equal( 3 ); + + expect( bookmarksView.listChildren.get( 0 ) ).is.instanceOf( ButtonView ); + expect( bookmarksView.listChildren.get( 1 ) ).is.instanceOf( ButtonView ); + expect( bookmarksView.listChildren.get( 2 ) ).is.instanceOf( ButtonView ); + + expect( bookmarksView.listChildren.get( 0 ).icon ).to.equal( bookmarkIcon ); + expect( bookmarksView.listChildren.get( 1 ).icon ).to.equal( bookmarkIcon ); + expect( bookmarksView.listChildren.get( 2 ).icon ).to.equal( bookmarkIcon ); + + expect( bookmarksView.listChildren.get( 0 ).label ).to.equal( 'aaa' ); + expect( bookmarksView.listChildren.get( 1 ).label ).to.equal( 'ccc' ); + expect( bookmarksView.listChildren.get( 2 ).label ).to.equal( 'zzz' ); + } ); + + it( 'should execute action after click the bookmark button', () => { + // First button from the list with bookmark name 'aaa'. + const bookmarkButton = bookmarksView.listChildren.get( 0 ); + const focusSpy = testUtils.sinon.spy( linkUIFeature.formView, 'focus' ); + + bookmarkButton.fire( 'execute' ); + + expect( linkUIFeature.formView.urlInputView.fieldView.value ).is.equal( '#aaa' ); + expect( linkUIFeature._balloon.visibleView ).to.be.equal( linkUIFeature.formView ); + expect( focusSpy.calledOnce ).to.be.true; + } ); + } ); + } ); + + describe( 'bookmarks view', () => { + it( 'can be opened by clicking the bookmarks button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + + const formView = linkUIFeature.formView; + const button = formView + .template.children[ 0 ] + .last // ul + .template.children[ 0 ] + .get( 0 ) // li + .template.children[ 0 ] + .get( 0 ); // button + + button.fire( 'execute' ); + bookmarksView = linkUIFeature.bookmarksView; + + expect( balloon.visibleView ).to.equal( bookmarksView ); + } ); + + it( 'can be closed by clicking the back button', () => { + const spy = sinon.spy(); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature.listenTo( linkUIFeature.bookmarksView, 'cancel', spy ); + linkUIFeature._addBookmarksView(); + + linkUIFeature.bookmarksView.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'can be closed by clicking the "esc" button', () => { + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + linkUIFeature.bookmarksView.keystrokes.press( { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + } ); + + expect( balloon.visibleView ).to.equal( linkUIFeature.formView ); + } ); + + it( 'should hide the UI and not focus editable upon clicking outside the UI', () => { + const spy = testUtils.sinon.spy( linkUIFeature, '_hideUI' ); + + setModelData( editor.model, 'f[o]o' ); + + linkUIFeature._showUI(); + linkUIFeature._addBookmarksView(); + + document.body.dispatchEvent( new Event( 'mousedown', { bubbles: true } ) ); + + sinon.assert.calledWithExactly( spy ); + expect( linkUIFeature._balloon.visibleView ).to.be.null; + } ); } ); } ); diff --git a/packages/ckeditor5-link/tests/ui/linkbookmarksview.js b/packages/ckeditor5-link/tests/ui/linkbookmarksview.js new file mode 100644 index 00000000000..7032a9312e6 --- /dev/null +++ b/packages/ckeditor5-link/tests/ui/linkbookmarksview.js @@ -0,0 +1,288 @@ +/** + * @license Copyright (c) 2003-2024, 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 { + KeystrokeHandler, + FocusTracker, + keyCodes +} from '@ckeditor/ckeditor5-utils'; + +import { + View, + ListView, + FocusCycler, + ViewCollection, + ButtonView +} from '@ckeditor/ckeditor5-ui'; + +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils.js'; + +import LinkBookmarksView from '../../src/ui/linkbookmarksview.js'; + +const mockLocale = { t: val => val }; + +describe( 'LinkBookmarksView', () => { + let view, bookmarksButtonsArrayMock; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkBookmarksView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + bookmarksButtonsArrayMock = [ + createButton( 'Mocked bookmark button 1' ), + createButton( 'Mocked bookmark button 2' ), + createButton( 'Mocked bookmark button 3' ) + ]; + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + describe( 'constructor()', () => { + it( 'should create element from template', () => { + expect( view.element.tagName.toLowerCase() ).to.equal( 'div' ); + expect( view.element.classList.contains( 'ck' ) ).to.true; + expect( view.element.classList.contains( 'ck-link__panel' ) ).to.true; + expect( view.element.classList.contains( 'ck-link__bookmarks' ) ).to.true; + expect( view.element.getAttribute( 'tabindex' ) ).to.equal( '-1' ); + } ); + + it( 'should create child views', () => { + expect( view.backButtonView ).to.be.instanceOf( ButtonView ); + expect( view.listView ).to.be.instanceOf( ListView ); + expect( view.emptyListInformation ).to.be.instanceOf( View ); + expect( view.children ).to.be.instanceOf( ViewCollection ); + expect( view.listChildren ).to.be.instanceOf( ViewCollection ); + expect( view.children ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should create #focusTracker instance', () => { + expect( view.focusTracker ).to.be.instanceOf( FocusTracker ); + } ); + + it( 'should create #keystrokes instance', () => { + expect( view.keystrokes ).to.be.instanceOf( KeystrokeHandler ); + } ); + + it( 'should create #_focusCycler instance', () => { + expect( view._focusCycler ).to.be.instanceOf( FocusCycler ); + } ); + + it( 'should create #_focusables view collection', () => { + expect( view._focusables ).to.be.instanceOf( ViewCollection ); + } ); + + it( 'should create #hasItems instance and set it to `false`', () => { + expect( view.hasItems ).to.be.equal( false ); + + view.listChildren.addMany( bookmarksButtonsArrayMock ); + + expect( view.hasItems ).to.be.equal( true ); + + view.listChildren.clear(); + + expect( view.hasItems ).to.be.equal( false ); + } ); + + it( 'should fire `cancel` event on backButtonView#execute', () => { + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.backButtonView.fire( 'execute' ); + + sinon.assert.calledOnce( spy ); + } ); + + describe( 'template', () => { + it( 'has back button', () => { + const button = view.template.children[ 0 ].get( 0 ).template.children[ 0 ].get( 0 ); + + expect( button ).to.equal( view.backButtonView ); + } ); + } ); + + it( 'should create emptyListInformation element from template', () => { + const emptyListInformation = view.emptyListInformation; + + expect( emptyListInformation.element.tagName.toLowerCase() ).to.equal( 'p' ); + expect( emptyListInformation.element.classList.contains( 'ck' ) ).to.true; + expect( emptyListInformation.element.classList.contains( 'ck-link__empty-list-info' ) ).to.true; + + expect( emptyListInformation.template.children[ 0 ].text[ 0 ] ).to.equal( 'No bookmarks available.' ); + } ); + } ); + + describe( 'bindings', () => { + it( 'should hide after Esc key press', () => { + const keyEvtData = { + keyCode: keyCodes.esc, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + const spy = sinon.spy(); + + view.on( 'cancel', spy ); + + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + } ); + } ); + + describe( 'render()', () => { + it( 'should register child views in #_focusables', () => { + expect( view._focusables.map( f => f ) ).to.have.members( [ + view.backButtonView, + view.listView + ] ); + } ); + + it( 'should register child views #element in #focusTracker', () => { + const view = new LinkBookmarksView( mockLocale ); + const spy = testUtils.sinon.spy( view.focusTracker, 'add' ); + + view.render(); + + sinon.assert.calledWithExactly( spy.getCall( 0 ), view.listView.element ); + sinon.assert.calledWithExactly( spy.getCall( 1 ), view.backButtonView.element ); + + view.destroy(); + } ); + + it( 'starts listening for #keystrokes coming from #element', () => { + const view = new LinkBookmarksView( mockLocale ); + const spy = sinon.spy( view.keystrokes, 'listenTo' ); + + view.render(); + + sinon.assert.calledOnce( spy ); + sinon.assert.calledWithExactly( spy, view.element ); + + view.destroy(); + } ); + + describe( 'activates keyboard navigation', () => { + let view; + + testUtils.createSinonSandbox(); + + beforeEach( () => { + view = new LinkBookmarksView( mockLocale ); + view.render(); + document.body.appendChild( view.element ); + + view.listChildren.addMany( bookmarksButtonsArrayMock ); + } ); + + afterEach( () => { + view.element.remove(); + view.destroy(); + } ); + + it( 'so "tab" focuses the next focusable item', () => { + expect( view.hasItems ).to.be.equal( true ); + + const spy = sinon.spy( view.backButtonView, 'focus' ); + const keyEvtData = { + keyCode: keyCodes.tab, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the focus on list. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.listView.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + + it( 'so "shift + tab" focuses the previous focusable item', () => { + expect( view.hasItems ).to.be.equal( true ); + + const spy = sinon.spy( view.listView, 'focus' ); + const keyEvtData = { + keyCode: keyCodes.tab, + shiftKey: true, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; + + // Mock the back button is focused. + view.focusTracker.isFocused = true; + view.focusTracker.focusedElement = view.backButtonView.element; + view.keystrokes.press( keyEvtData ); + + sinon.assert.calledOnce( keyEvtData.preventDefault ); + sinon.assert.calledOnce( keyEvtData.stopPropagation ); + sinon.assert.calledOnce( spy ); + } ); + } ); + } ); + + describe( 'destroy()', () => { + it( 'should destroy the FocusTracker instance', () => { + const destroySpy = sinon.spy( view.focusTracker, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + + it( 'should destroy the KeystrokeHandler instance', () => { + const destroySpy = sinon.spy( view.keystrokes, 'destroy' ); + + view.destroy(); + + sinon.assert.calledOnce( destroySpy ); + } ); + } ); + + describe( 'focus()', () => { + it( 'focuses the back button when bookmarks list is empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( backButtonSpy ); + } ); + + it( 'focuses the back button when bookmarks list is not empty', () => { + const backButtonSpy = sinon.spy( view.backButtonView, 'focus' ); + + view.listChildren.addMany( bookmarksButtonsArrayMock ); + + const listItemSpy = sinon.spy( view.listChildren.first, 'focus' ); + + view.focus(); + + sinon.assert.notCalled( backButtonSpy ); + sinon.assert.calledOnce( listItemSpy ); + } ); + } ); + + function createButton( label ) { + const button = new ButtonView( mockLocale ); + + button.set( { + label, + withText: true + } ); + + return button; + } +} ); diff --git a/packages/ckeditor5-link/theme/linkform.css b/packages/ckeditor5-link/theme/linkform.css index 02e531ed93d..ec96063bad6 100644 --- a/packages/ckeditor5-link/theme/linkform.css +++ b/packages/ckeditor5-link/theme/linkform.css @@ -5,8 +5,13 @@ @import "@ckeditor/ckeditor5-ui/theme/mixins/_rwd.css"; +:root { + --ck-link-panel-width: 340px; + --ck-link-list-view-max-height: 240px; +} + .ck.ck-link__panel { - width: 340px; + width: var(--ck-link-panel-width); &:focus { outline: none; @@ -50,6 +55,10 @@ & .ck-link-and-submit { display: flex; + & > .ck-labeled-field-view { + flex: 1; + } + & .ck-button-insert { padding: var(--ck-spacing-tiny) var(--ck-spacing-large); margin-left: var(--ck-spacing-medium); @@ -84,10 +93,39 @@ & .ck-link__button { padding: var(--ck-spacing-medium) var(--ck-spacing-large); border-radius: 0; - + & > .ck-button__label { flex-grow: 1; } } } + + & .ck-link__empty-list-info { + padding: calc( 2 * var(--ck-spacing-large) ) var(--ck-spacing-medium); + text-align: center; + font-style: italic; + } + + & .ck-link__items:empty { + display: none; + } + + & > .ck-list__bookmark-items { + max-height: min( var(--ck-link-list-view-max-height), 40vh ); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + + & .ck-button { + & > .ck-icon { + flex-shrink: 0; + } + + & > .ck-button__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } }