diff --git a/docs/framework/architecture/ui-components.md b/docs/framework/architecture/ui-components.md index 690dc261e94..f6e2fae5a01 100644 --- a/docs/framework/architecture/ui-components.md +++ b/docs/framework/architecture/ui-components.md @@ -599,11 +599,16 @@ import loupe from '@ckeditor/ckeditor5-core/theme/icons/loupe.svg'; {@snippet framework/ui/ui-input} -There are also inputs in the CKEditor 5 UI library. There are a few use cases to put inputs inside a main toolbar, but you also can add them to balloon panels. +The CKEditor 5 UI library contains a few input elements. Usually, they are used in dropdowns and balloon panels, but you can also use them in a main toolbar. + +To create them, use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class, which takes two parameters: + +* an instance of the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} class, +* a helper function, depending on the type of field you want to create. ### Text -You can use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabaledFieldView`} class to instantiate an input. It takes two parameters: {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} and a helper function. Pass the {@link module:ui/labeledfield/utils#createLabeledInputText `createLabeledInputText()`} helper function to create a text input. +To create a text field, pass the {@link module:ui/labeledfield/utils#createLabeledInputText `createLabeledInputText()`} helper function as the second parameter to the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class. ```js import { createLabeledInputText, LabeledFieldView } from '@ckeditor/ckeditor5-ui'; @@ -620,7 +625,7 @@ document.getElementById( 'input-text' ).append( textInput.element ); ### Number -You can use the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabaledFieldView`} class to instantiate an input. It takes two parameters: {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#locale `locale`} and a helper function. Pass the {@link module:ui/labeledfield/utils#createLabeledInputNumber `createLabeledInputNumber()`} helper function to create a number input. +To create a number field, pass the {@link module:ui/labeledfield/utils#createLabeledInputNumber `createLabeledInputNumber()`} helper function as the second parameter to the {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView `LabeledFieldView`} class. ```js import { createLabeledInputNumber, LabeledFieldView } from '@ckeditor/ckeditor5-ui'; diff --git a/docs/updating/update-to-41.md b/docs/updating/update-to-41.md index 5293ce7e19a..6fecbb9fc73 100644 --- a/docs/updating/update-to-41.md +++ b/docs/updating/update-to-41.md @@ -134,7 +134,6 @@ Among other changes, some icons have been moved around the project. Observe thes The following icons were moved to the `@ckeditor/ckeditor5-core` package: `browse-files`, `bulletedlist`, `codeblock`, `color-palette`, `heading1`, `heading2`, `heading3`, `heading4`, `heading5`, `heading6`, `horizontalline`, `html`, `indent`, `next-arrow`, `numberedlist`, `outdent`, `previous-arrow`, `redo`, `table`,`todolist`, `undo`. - ### Exports renaming Some exports names were changed due to possibility of name conflicts: @@ -149,3 +148,20 @@ Some exports names were changed due to possibility of name conflicts: The code we distribute in our npm packages uses the [ES Module syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) (for example `import X from 'y'`). Until now it was not fully compliant with the standard and the packages were not properly marked as ES module. In some cases this resulted in bundlers (like Vite) and other tools (such as Vitest) failing to build or run the projects containing CKEditor 5. It required workarounds in their configuration. In this release we fix these issues, meaning that our packages are now fully ESM compliant and these workarounds are no longer needed. + +### Added validation to the URL field in the Link form + +Until now, the form for adding a URL to the selected text accepted an empty value, leaving the `href` empty. We believe this is undesirable in most cases, so we've added a validation to prevent adding a link if the field is empty. + +However, if for some reason you want to allow empty links, you can do so using the new `allowCreatingEmptyLinks` configuration option we've added to the `link` plugin. + +```diff +ClassicEditor + .create( editorElement, { + link: { ++ allowCreatingEmptyLinks: true + } + } ) + .then( ... ) + .catch( ... ); +``` diff --git a/packages/ckeditor5-link/src/linkconfig.ts b/packages/ckeditor5-link/src/linkconfig.ts index 73079798a7d..47aac11e342 100644 --- a/packages/ckeditor5-link/src/linkconfig.ts +++ b/packages/ckeditor5-link/src/linkconfig.ts @@ -48,6 +48,26 @@ export interface LinkConfig { */ defaultProtocol?: string; + /** + * When set to `true`, the form will accept an empty value in the URL field, creating a link with an empty `href` (``). + * + * ```ts + * ClassicEditor + * .create( editorElement, { + * link: { + * allowCreatingEmptyLinks: true + * } + * } ) + * .then( ... ) + * .catch( ... ); + * ``` + * + * **NOTE:** This option only adds form validation. If a link with an empty `href` is loaded into the editor, it will be left as-is. + * + * @default false + */ + allowCreatingEmptyLinks?: boolean; + /** * When set to `true`, the `target="blank"` and `rel="noopener noreferrer"` attributes are automatically added to all external links * in the editor. "External links" are all links in the editor content starting with `http`, `https`, or `//`. diff --git a/packages/ckeditor5-link/src/linkediting.ts b/packages/ckeditor5-link/src/linkediting.ts index 49095b6391f..29172ece10e 100644 --- a/packages/ckeditor5-link/src/linkediting.ts +++ b/packages/ckeditor5-link/src/linkediting.ts @@ -80,6 +80,7 @@ export default class LinkEditing extends Plugin { super( editor ); editor.config.define( 'link', { + allowCreatingEmptyLinks: false, addTargetToExternalLinks: false } ); } diff --git a/packages/ckeditor5-link/src/linkui.ts b/packages/ckeditor5-link/src/linkui.ts index 62e064e9c87..b2cf6d8ff57 100644 --- a/packages/ckeditor5-link/src/linkui.ts +++ b/packages/ckeditor5-link/src/linkui.ts @@ -176,6 +176,7 @@ export default class LinkUI extends Plugin { const editor = this.editor; const linkCommand: LinkCommand = editor.commands.get( 'link' )!; const defaultProtocol = editor.config.get( 'link.defaultProtocol' ); + const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); const formView = new ( CssTransitionDisablerMixin( LinkFormView ) )( editor.locale, linkCommand ); @@ -183,7 +184,13 @@ export default class LinkUI extends Plugin { // Form elements should be read-only when corresponding commands are disabled. formView.urlInputView.bind( 'isEnabled' ).to( linkCommand, 'isEnabled' ); - formView.saveButtonView.bind( 'isEnabled' ).to( linkCommand ); + + // Disable the "save" button if the command is disabled or the input is empty despite being required. + formView.saveButtonView.bind( 'isEnabled' ).to( + linkCommand, 'isEnabled', + formView.urlInputView, 'isEmpty', + ( isCommandEnabled, isInputEmpty ) => isCommandEnabled && ( allowCreatingEmptyLinks || !isInputEmpty ) + ); // Execute link command after clicking the "Save" button. this.listenTo( formView, 'submit', () => { @@ -390,6 +397,9 @@ export default class LinkUI extends Plugin { // See https://github.com/ckeditor/ckeditor5/issues/1501. this.formView!.saveButtonView.focus(); + // Reset the URL field to update the state of the submit button. + this.formView!.urlInputView.fieldView.reset(); + this._balloon.remove( this.formView! ); // Because the form has an input which has focus, the focus must be brought back diff --git a/packages/ckeditor5-link/tests/linkediting.js b/packages/ckeditor5-link/tests/linkediting.js index a385ca068e5..bdaa0315584 100644 --- a/packages/ckeditor5-link/tests/linkediting.js +++ b/packages/ckeditor5-link/tests/linkediting.js @@ -633,10 +633,15 @@ describe( 'LinkEditing', () => { url: 'tel:123456789' } ]; - it( 'link.addTargetToExternalLinks is predefined as false value', () => { + + it( 'link.addTargetToExternalLinks has a default value of `false`', () => { expect( editor.config.get( 'link.addTargetToExternalLinks' ) ).to.be.false; } ); + it( 'link.allowCreatingEmptyLinks has a default value of `false`', () => { + expect( editor.config.get( 'link.allowCreatingEmptyLinks' ) ).to.be.false; + } ); + describe( 'for link.addTargetToExternalLinks = false', () => { let editor, model; diff --git a/packages/ckeditor5-link/tests/linkui.js b/packages/ckeditor5-link/tests/linkui.js index 31d723bebf8..9d3572e1635 100644 --- a/packages/ckeditor5-link/tests/linkui.js +++ b/packages/ckeditor5-link/tests/linkui.js @@ -238,6 +238,8 @@ describe( 'LinkUI', () => { formView = linkUIFeature.formView; actionsView = linkUIFeature.actionsView; + formView.urlInputView.fieldView.value = 'ckeditor.com'; + editor.commands.get( 'link' ).isEnabled = true; editor.commands.get( 'unlink' ).isEnabled = true; @@ -1403,6 +1405,30 @@ describe( 'LinkUI', () => { } ); }; + const createEditorWithEmptyLinks = allowCreatingEmptyLinks => { + return ClassicTestEditor + .create( editorElement, { + plugins: [ LinkEditing, LinkUI, Paragraph, BlockQuote ], + link: { allowCreatingEmptyLinks } + } ) + .then( editor => { + const linkUIFeature = editor.plugins.get( LinkUI ); + + linkUIFeature._createViews(); + + const formView = linkUIFeature.formView; + + formView.render(); + + editor.model.schema.extend( '$text', { + allowIn: '$root', + allowAttributes: 'linkHref' + } ); + + return { editor, formView }; + } ); + }; + beforeEach( () => { // Make sure that forms are lazy initiated. expect( linkUIFeature.formView ).to.be.null; @@ -1427,6 +1453,40 @@ describe( 'LinkUI', () => { expect( editor.ui.focusTracker.isFocused ).to.be.true; } ); + describe( 'empty links', () => { + it( 'should not allow empty links by default', () => { + const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); + + expect( allowCreatingEmptyLinks ).to.equal( false ); + } ); + + it( 'should allow enabling empty links', () => { + return createEditorWithEmptyLinks( true ).then( ( { editor } ) => { + const allowCreatingEmptyLinks = editor.config.get( 'link.allowCreatingEmptyLinks' ); + + expect( allowCreatingEmptyLinks ).to.equal( true ); + + return editor.destroy(); + } ); + } ); + + it( 'should not allow submitting empty form when link is required', () => { + return createEditorWithEmptyLinks( false ).then( ( { editor, formView } ) => { + expect( formView.saveButtonView.isEnabled ).to.be.false; + + return editor.destroy(); + } ); + } ); + + it( 'should allow submitting empty form when link is not required', () => { + return createEditorWithEmptyLinks( true ).then( ( { editor, formView } ) => { + expect( formView.saveButtonView.isEnabled ).to.be.true; + + return editor.destroy(); + } ); + } ); + } ); + describe( 'link protocol', () => { it( 'should use a default link protocol from the `config.link.defaultProtocol` when provided', () => { return ClassicTestEditor @@ -1445,10 +1505,14 @@ describe( 'LinkUI', () => { } ); it( 'should not add a protocol without the configuration', () => { + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + formView.urlInputView.fieldView.value = 'ckeditor.com'; formView.fire( 'submit' ); - expect( formView.urlInputView.fieldView.value ).to.equal( 'ckeditor.com' ); + sinon.assert.calledWith( linkCommandSpy, 'ckeditor.com', sinon.match.any ); + + return editor.destroy(); } ); it( 'should not add a protocol to the local links even when `config.link.defaultProtocol` configured', () => { @@ -1481,7 +1545,6 @@ describe( 'LinkUI', () => { formView.urlInputView.fieldView.value = 'http://example.com'; formView.fire( 'submit' ); - expect( formView.urlInputView.fieldView.value ).to.equal( 'http://example.com' ); sinon.assert.calledWith( linkCommandSpy, 'http://example.com', sinon.match.any ); return editor.destroy(); @@ -1536,7 +1599,6 @@ describe( 'LinkUI', () => { formView.urlInputView.fieldView.value = 'email@example.com'; formView.fire( 'submit' ); - expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:email@example.com' ); expect( getModelData( editor.model ) ).to.equal( '[<$text linkHref="mailto:email@example.com">email@example.com]' ); @@ -1552,19 +1614,20 @@ describe( 'LinkUI', () => { formView.urlInputView.fieldView.value = 'email@example.com'; formView.fire( 'submit' ); - expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:email@example.com' ); expect( getModelData( editor.model ) ).to.equal( '[<$text linkHref="mailto:email@example.com">email@example.com]' ); } ); - it( 'should not add an email protocol when given provided within the value' + + it( 'should not add an email protocol when given provided within the value ' + 'even when `config.link.defaultProtocol` configured', () => { return createEditorWithDefaultProtocol( 'mailto:' ).then( ( { editor, formView } ) => { + const linkCommandSpy = sinon.spy( editor.commands.get( 'link' ), 'execute' ); + formView.urlInputView.fieldView.value = 'mailto:test@example.com'; formView.fire( 'submit' ); - expect( formView.urlInputView.fieldView.value ).to.equal( 'mailto:test@example.com' ); + sinon.assert.calledWith( linkCommandSpy, 'mailto:test@example.com', sinon.match.any ); return editor.destroy(); } ); diff --git a/packages/ckeditor5-ui/src/labeledfield/utils.ts b/packages/ckeditor5-ui/src/labeledfield/utils.ts index 02597e47c9c..f898a933374 100644 --- a/packages/ckeditor5-ui/src/labeledfield/utils.ts +++ b/packages/ckeditor5-ui/src/labeledfield/utils.ts @@ -37,9 +37,9 @@ import type { LabeledFieldViewCreator } from './labeledfieldview.js'; * ``` * * @param labeledFieldView The instance of the labeled field view. - * @param viewUid An UID string that allows DOM logical connection between the + * @param viewUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the input. - * @param statusUid An UID string that allows DOM logical connection between the + * @param statusUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input. * @returns The input text view instance. */ @@ -86,9 +86,9 @@ const createLabeledInputText: LabeledFieldViewCreator = ( labeled * ``` * * @param labeledFieldView The instance of the labeled field view. - * @param viewUid An UID string that allows DOM logical connection between the + * @param viewUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the input. - * @param statusUid An UID string that allows DOM logical connection between the + * @param statusUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the input. * @returns The input number view instance. */ @@ -136,9 +136,9 @@ const createLabeledInputNumber: LabeledFieldViewCreator = ( lab * ``` * * @param labeledFieldView The instance of the labeled field view. - * @param viewUid An UID string that allows DOM logical connection between the + * @param viewUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view's label} and the textarea. - * @param statusUid An UID string that allows DOM logical connection between the + * @param statusUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view's status} and the textarea. * @returns The textarea view instance. */ @@ -183,9 +183,9 @@ const createLabeledTextarea: LabeledFieldViewCreator = ( labeledFi * ``` * * @param labeledFieldView The instance of the labeled field view. - * @param viewUid An UID string that allows DOM logical connection between the + * @param viewUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#labelView labeled view label} and the dropdown. - * @param statusUid An UID string that allows DOM logical connection between the + * @param statusUid A UID string that allows DOM logical connection between the * {@link module:ui/labeledfield/labeledfieldview~LabeledFieldView#statusView labeled view status} and the dropdown. * @returns The dropdown view instance. */