diff --git a/packages/ckeditor5-alignment/src/alignmentui.js b/packages/ckeditor5-alignment/src/alignmentui.js index b6fff324537..509b7fa1b9c 100644 --- a/packages/ckeditor5-alignment/src/alignmentui.js +++ b/packages/ckeditor5-alignment/src/alignmentui.js @@ -79,7 +79,7 @@ export default class AlignmentUI extends Plugin { // Add existing alignment buttons to dropdown's toolbar. const buttons = options.map( option => componentFactory.create( `alignment:${ option.name }` ) ); - addToolbarToDropdown( dropdownView, buttons ); + addToolbarToDropdown( dropdownView, buttons, { enableActiveItemFocusOnDropdownOpen: true } ); // Configure dropdown properties an behavior. dropdownView.buttonView.set( { diff --git a/packages/ckeditor5-alignment/tests/alignmentui.js b/packages/ckeditor5-alignment/tests/alignmentui.js index 98556d521b3..2b6dd429c98 100644 --- a/packages/ckeditor5-alignment/tests/alignmentui.js +++ b/packages/ckeditor5-alignment/tests/alignmentui.js @@ -259,6 +259,17 @@ describe( 'Alignment UI', () => { expect( items.includes( 'Justify' ) ).to.be.true; } ); + it( 'should focus the first active button when dropdown is opened', () => { + const buttonAlignLeft = dropdown.toolbarView.items.get( 0 ); + const buttonAlignRight = dropdown.toolbarView.items.get( 1 ); + const spy = sinon.spy( buttonAlignRight, 'focus' ); + + buttonAlignLeft.isOn = false; + buttonAlignRight.isOn = true; + dropdown.isOpen = true; + sinon.assert.calledOnce( spy ); + } ); + describe( 'config', () => { beforeEach( async () => { // Clean up the editor created in main test suite hook. diff --git a/packages/ckeditor5-font/src/ui/colorui.js b/packages/ckeditor5-font/src/ui/colorui.js index 516b6fc3c0b..091bfd2b587 100644 --- a/packages/ckeditor5-font/src/ui/colorui.js +++ b/packages/ckeditor5-font/src/ui/colorui.js @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { createDropdown, normalizeColorOptions, getLocalizedColorOptions } from 'ckeditor5/src/ui'; +import { createDropdown, normalizeColorOptions, getLocalizedColorOptions, focusChildOnDropdownOpen } from 'ckeditor5/src/ui'; import { addColorTableToDropdown } from '../utils'; @@ -141,6 +141,9 @@ export default class ColorUI extends Plugin { } } ); + // Accessibility: focus the first active color when opening the dropdown. + focusChildOnDropdownOpen( dropdownView, () => dropdownView.colorTableView.staticColorsGrid.items.find( item => item.isOn ) ); + return dropdownView; } ); } diff --git a/packages/ckeditor5-font/tests/ui/colorui.js b/packages/ckeditor5-font/tests/ui/colorui.js index e302444b08f..26e7bbe66af 100644 --- a/packages/ckeditor5-font/tests/ui/colorui.js +++ b/packages/ckeditor5-font/tests/ui/colorui.js @@ -163,6 +163,20 @@ describe( 'ColorUI', () => { } } ); + it( 'should focus the first active button when dropdown is opened', () => { + global.document.body.appendChild( dropdown.element ); + + const secondButton = dropdown.colorTableView.staticColorsGrid.items.get( 1 ); + const spy = sinon.spy( secondButton, 'focus' ); + + secondButton.isOn = true; + dropdown.isOpen = false; + dropdown.isOpen = true; + sinon.assert.calledOnce( spy ); + + dropdown.element.remove(); + } ); + describe( 'model to command binding', () => { it( 'isEnabled', () => { command.isEnabled = false; diff --git a/packages/ckeditor5-highlight/src/highlightui.js b/packages/ckeditor5-highlight/src/highlightui.js index afaf827b6a4..9ec9b358b26 100644 --- a/packages/ckeditor5-highlight/src/highlightui.js +++ b/packages/ckeditor5-highlight/src/highlightui.js @@ -219,7 +219,7 @@ export default class HighlightUI extends Plugin { buttons.push( new ToolbarSeparatorView() ); buttons.push( componentFactory.create( 'removeHighlight' ) ); - addToolbarToDropdown( dropdownView, buttons ); + addToolbarToDropdown( dropdownView, buttons, { enableActiveItemFocusOnDropdownOpen: true } ); bindToolbarIconStyleToActiveColor( dropdownView ); dropdownView.toolbarView.ariaLabel = t( 'Text highlight toolbar' ); diff --git a/packages/ckeditor5-highlight/tests/highlightui.js b/packages/ckeditor5-highlight/tests/highlightui.js index 6f4d6635c13..c60b230442a 100644 --- a/packages/ckeditor5-highlight/tests/highlightui.js +++ b/packages/ckeditor5-highlight/tests/highlightui.js @@ -132,6 +132,15 @@ describe( 'HighlightUI', () => { .to.deep.equal( [ false, true, false, false, false, false, undefined, false ] ); } ); + it( 'should focus the first active button when dropdown is opened', () => { + const greenMarker = dropdown.toolbarView.items.get( 1 ); + const spy = sinon.spy( greenMarker, 'focus' ); + + greenMarker.isOn = true; + dropdown.isOpen = true; + sinon.assert.calledOnce( spy ); + } ); + it( 'should mark as toggleable all markers and pens', () => { const toolbar = dropdown.toolbarView; diff --git a/packages/ckeditor5-image/src/imageinsert/imageinsertui.js b/packages/ckeditor5-image/src/imageinsert/imageinsertui.js index 15ba4107851..21c58f1b9ab 100644 --- a/packages/ckeditor5-image/src/imageinsert/imageinsertui.js +++ b/packages/ckeditor5-image/src/imageinsert/imageinsertui.js @@ -130,8 +130,6 @@ export default class ImageInsertUI extends Plugin { const selectedElement = editor.model.document.selection.getSelectedElement(); if ( dropdownView.isOpen ) { - imageInsertView.focus(); - if ( imageUtils.isImage( selectedElement ) ) { imageInsertView.imageURLInputValue = selectedElement.getAttribute( 'src' ); insertButtonView.label = t( 'Update' ); diff --git a/packages/ckeditor5-image/src/imagestyle/imagestyleui.js b/packages/ckeditor5-image/src/imagestyle/imagestyleui.js index e1b88082d64..d5ac995de70 100644 --- a/packages/ckeditor5-image/src/imagestyle/imagestyleui.js +++ b/packages/ckeditor5-image/src/imagestyle/imagestyleui.js @@ -131,7 +131,7 @@ export default class ImageStyleUI extends Plugin { const splitButtonView = dropdownView.buttonView; const splitButtonViewArrow = splitButtonView.arrowView; - addToolbarToDropdown( dropdownView, buttonViews ); + addToolbarToDropdown( dropdownView, buttonViews, { enableActiveItemFocusOnDropdownOpen: true } ); splitButtonView.set( { label: getDropdownButtonTitle( title, defaultButton.label ), diff --git a/packages/ckeditor5-image/tests/imagestyle/imagestyleui.js b/packages/ckeditor5-image/tests/imagestyle/imagestyleui.js index 040da125a7d..054b6cbe74f 100644 --- a/packages/ckeditor5-image/tests/imagestyle/imagestyleui.js +++ b/packages/ckeditor5-image/tests/imagestyle/imagestyleui.js @@ -260,6 +260,17 @@ describe( 'ImageStyleUI', () => { } } ); + it( 'should focus the first active button when dropdown is opened', () => { + for ( const { view } of dropdowns ) { + const secondButton = view.toolbarView.items.get( 1 ); + const spy = sinon.spy( secondButton, 'focus' ); + + secondButton.isOn = true; + view.isOpen = true; + sinon.assert.calledOnce( spy ); + } + } ); + it( 'should keep the same label of the secondary (arrow) button when the user changes styles of the image', () => { const dropdownView = editor.ui.componentFactory.create( 'imageStyle:breakText' ); diff --git a/packages/ckeditor5-list/src/listproperties/listpropertiesui.js b/packages/ckeditor5-list/src/listproperties/listpropertiesui.js index 1a96e46974e..df427070417 100644 --- a/packages/ckeditor5-list/src/listproperties/listpropertiesui.js +++ b/packages/ckeditor5-list/src/listproperties/listpropertiesui.js @@ -8,7 +8,7 @@ */ import { Plugin } from 'ckeditor5/src/core'; -import { ButtonView, SplitButtonView, createDropdown } from 'ckeditor5/src/ui'; +import { ButtonView, SplitButtonView, createDropdown, focusChildOnDropdownOpen } from 'ckeditor5/src/ui'; import ListPropertiesView from './ui/listpropertiesview'; @@ -184,6 +184,9 @@ function getDropdownViewCreator( { editor, parentCommandName, buttonLabel, butto dropdownView.panelView.children.add( listPropertiesView ); + // Accessibility: focus the first active style when opening the dropdown. + focusChildOnDropdownOpen( dropdownView, () => listPropertiesView.stylesView.children.find( child => child.isOn ) ); + return dropdownView; }; } diff --git a/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js b/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js index d68954f3479..f79a22816fd 100644 --- a/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js +++ b/packages/ckeditor5-list/tests/listproperties/listpropertiesui.js @@ -268,6 +268,15 @@ describe( 'ListPropertiesUI', () => { sinon.assert.calledOnce( spy ); } ); + it( 'on dropdown open should focus the first active button', () => { + const button = stylesView.children.get( 1 ); + const spy = sinon.spy( button, 'focus' ); + + button.isOn = true; + bulletedListDropdown.isOpen = true; + sinon.assert.calledOnce( spy ); + } ); + describe( 'style button', () => { let styleButtonView; @@ -558,6 +567,15 @@ describe( 'ListPropertiesUI', () => { sinon.assert.calledOnce( spy ); } ); + it( 'on dropdown open should focus the first active button', () => { + const button = stylesView.children.get( 1 ); + const spy = sinon.spy( button, 'focus' ); + + button.isOn = true; + numberedListDropdown.isOpen = true; + sinon.assert.calledOnce( spy ); + } ); + describe( 'style button', () => { let styleButtonView; diff --git a/packages/ckeditor5-media-embed/src/mediaembedui.js b/packages/ckeditor5-media-embed/src/mediaembedui.js index bf799ecef5d..a9327ae3b03 100644 --- a/packages/ckeditor5-media-embed/src/mediaembedui.js +++ b/packages/ckeditor5-media-embed/src/mediaembedui.js @@ -87,7 +87,6 @@ export default class MediaEmbedUI extends Plugin { // command. form.url = command.value || ''; form.urlInputView.fieldView.select(); - form.focus(); form.enableCssTransitions(); }, { priority: 'low' } ); diff --git a/packages/ckeditor5-media-embed/tests/mediaembedui.js b/packages/ckeditor5-media-embed/tests/mediaembedui.js index d1f10c41c86..f72800af56e 100644 --- a/packages/ckeditor5-media-embed/tests/mediaembedui.js +++ b/packages/ckeditor5-media-embed/tests/mediaembedui.js @@ -121,13 +121,6 @@ describe( 'MediaEmbedUI', () => { sinon.assert.calledOnce( spy ); } ); - it( 'should focus the form', () => { - const spy = sinon.spy( form, 'focus' ); - - button.fire( 'open' ); - sinon.assert.calledOnce( spy ); - } ); - it( 'should disable CSS transitions to avoid unnecessary animations (and then enable them again)', () => { const disableCssTransitionsSpy = sinon.spy( form, 'disableCssTransitions' ); const enableCssTransitionsSpy = sinon.spy( form, 'enableCssTransitions' ); diff --git a/packages/ckeditor5-special-characters/src/ui/specialcharactersnavigationview.js b/packages/ckeditor5-special-characters/src/ui/specialcharactersnavigationview.js index c2650125564..109a80c2bba 100644 --- a/packages/ckeditor5-special-characters/src/ui/specialcharactersnavigationview.js +++ b/packages/ckeditor5-special-characters/src/ui/specialcharactersnavigationview.js @@ -59,6 +59,13 @@ export default class SpecialCharactersNavigationView extends FormHeaderView { return this.groupDropdownView.value; } + /** + * Focuses the character categories dropdown. + */ + focus() { + this.groupDropdownView.focus(); + } + /** * Returns a dropdown that allows selecting character groups. * diff --git a/packages/ckeditor5-special-characters/tests/ui/specialcharactersnavigationview.js b/packages/ckeditor5-special-characters/tests/ui/specialcharactersnavigationview.js index 50b1d6f1fa5..854f361a1eb 100644 --- a/packages/ckeditor5-special-characters/tests/ui/specialcharactersnavigationview.js +++ b/packages/ckeditor5-special-characters/tests/ui/specialcharactersnavigationview.js @@ -149,4 +149,14 @@ describe( 'SpecialCharactersNavigationView', () => { } ); } ); } ); + + describe( 'focus()', () => { + it( 'focuses the character categories dropdown', () => { + const spy = sinon.spy( view.groupDropdownView, 'focus' ); + + view.focus(); + + sinon.assert.calledOnce( spy ); + } ); + } ); } ); diff --git a/packages/ckeditor5-ui/src/button/buttonview.js b/packages/ckeditor5-ui/src/button/buttonview.js index e71a41404cc..744f4a2ee9e 100644 --- a/packages/ckeditor5-ui/src/button/buttonview.js +++ b/packages/ckeditor5-ui/src/button/buttonview.js @@ -151,10 +151,6 @@ export default class ButtonView extends View { children: this.children, on: { - mousedown: bind.to( evt => { - evt.preventDefault(); - } ), - click: bind.to( evt => { // We can't make the button disabled using the disabled attribute, because it won't be focusable. // Though, shouldn't this condition be moved to the button controller? diff --git a/packages/ckeditor5-ui/src/dropdown/dropdownpanelview.js b/packages/ckeditor5-ui/src/dropdown/dropdownpanelview.js index a9688f8d704..dd3f4ac5385 100644 --- a/packages/ckeditor5-ui/src/dropdown/dropdownpanelview.js +++ b/packages/ckeditor5-ui/src/dropdown/dropdownpanelview.js @@ -8,6 +8,7 @@ */ import View from '../view'; +import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; /** * The dropdown panel view class. @@ -81,13 +82,32 @@ export default class DropdownPanelView extends View { } /** - * Focuses the view element or first item in view collection on opening dropdown's panel. + * Focuses the first view in the {@link #children} collection. * * See also {@link module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable}. */ focus() { if ( this.children.length ) { - this.children.first.focus(); + if ( typeof this.children.first.focus === 'function' ) { + this.children.first.focus(); + } else { + /** + * The child view of a dropdown could not be focused because it is missing the `focus()` method. + * + * This warning appears when a dropdown {@link module:ui/dropdown/dropdownview~DropdownView#isOpen gets open} and it + * attempts to focus the {@link module:ui/dropdown/dropdownpanelview~DropdownPanelView#children first child} of its panel + * but the child does not implement the + * {@link module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable focusable interface}. + * + * Focusing the content of a dropdown on open greatly improves the accessibility. Please make sure the view instance + * provides the `focus()` method for the best user experience. + * + * @error ui-dropdown-panel-focus-child-missing-focus + * @param {module:ui/view~View} childView + * @param {module:ui/dropdown/dropdownpanelview~DropdownPanelView} dropdownPanel + */ + logWarning( 'ui-dropdown-panel-focus-child-missing-focus', { childView: this.children.first, dropdownPanel: this } ); + } } } diff --git a/packages/ckeditor5-ui/src/dropdown/dropdownview.js b/packages/ckeditor5-ui/src/dropdown/dropdownview.js index 1c8664ade00..2853601e1d2 100644 --- a/packages/ckeditor5-ui/src/dropdown/dropdownview.js +++ b/packages/ckeditor5-ui/src/dropdown/dropdownview.js @@ -106,6 +106,10 @@ export default class DropdownView extends View { /** * Controls whether the dropdown view is open, i.e. shows or hides the {@link #panelView panel}. * + * **Note**: When the dropdown gets open, it will attempt to call `focus()` on the first child of its {@link #panelView}. + * See {@link module:ui/dropdown/utils~addToolbarToDropdown}, {@link module:ui/dropdown/utils~addListToDropdown}, and + * {@link module:ui/dropdown/utils~focusChildOnDropdownOpen} to learn more about focus management in dropdowns. + * * @observable * @member {Boolean} #isOpen */ @@ -263,6 +267,9 @@ export default class DropdownView extends View { } else { this.panelView.position = this.panelPosition; } + + // Focus the first item in the dropdown when the dropdown opened + this.panelView.focus(); } ); // Listen for keystrokes coming from within #element. diff --git a/packages/ckeditor5-ui/src/dropdown/utils.js b/packages/ckeditor5-ui/src/dropdown/utils.js index c881534632e..9afd45d1668 100644 --- a/packages/ckeditor5-ui/src/dropdown/utils.js +++ b/packages/ckeditor5-ui/src/dropdown/utils.js @@ -19,6 +19,8 @@ import SwitchButtonView from '../button/switchbuttonview'; import clickOutsideHandler from '../bindings/clickoutsidehandler'; +import { logWarning } from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; + import '../../theme/components/dropdown/toolbardropdown.css'; import '../../theme/components/dropdown/listdropdown.css'; @@ -123,12 +125,22 @@ export function createDropdown( locale, ButtonClass = DropdownButtonView ) { * dropdown.render() * document.body.appendChild( dropdown.element ); * + * **Note:** To improve the accessibility, you can tell the dropdown to focus the first active button of the toolbar when the dropdown + * {@link module:ui/dropdown/dropdownview~DropdownView#isOpen gets open}. See the documentation of `options` to learn more. + * * See {@link module:ui/dropdown/utils~createDropdown} and {@link module:ui/toolbar/toolbarview~ToolbarView}. * * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdown instance to which `ToolbarView` will be added. * @param {Iterable.} buttons + * @param {Object} [options] + * @param {Boolean} [options.enableActiveItemFocusOnDropdownOpen=false] When set `true`, the focus will automatically move to the first + * active {@link module:ui/toolbar/toolbar~ToolbarView#items item} of the toolbar upon + * {@link module:ui/dropdown/dropdownview~DropdownView#isOpen opening} the dropdown. Active items are those with the `isOn` property set + * `true` (for instance {@link module:ui/button/buttonview~ButtonView buttons}). If no active items is found, the toolbar will be focused + * as a whole resulting in the focus moving to its first focusable item (default behavior of + * {@link module:ui/dropdown/dropdownview~DropdownView}). */ -export function addToolbarToDropdown( dropdownView, buttons ) { +export function addToolbarToDropdown( dropdownView, buttons, options = {} ) { const locale = dropdownView.locale; const t = locale.t; const toolbarView = dropdownView.toolbarView = new ToolbarView( locale ); @@ -143,6 +155,11 @@ export function addToolbarToDropdown( dropdownView, buttons ) { buttons.map( view => toolbarView.items.add( view ) ); + if ( options.enableActiveItemFocusOnDropdownOpen ) { + // Accessibility: Focus the first active button in the toolbar when the dropdown gets open. + focusChildOnDropdownOpen( dropdownView, () => toolbarView.items.find( item => item.isOn ) ); + } + dropdownView.panelView.children.add( toolbarView ); toolbarView.items.delegate( 'execute' ).to( dropdownView ); } @@ -182,6 +199,9 @@ export function addToolbarToDropdown( dropdownView, buttons ) { * The `items` collection passed to this methods controls the presence and attributes of respective * {@link module:ui/list/listitemview~ListItemView list items}. * + * **Note:** To improve the accessibility, when a list is added to the dropdown using this helper the dropdown will automatically attempt + * to focus the first active item (a host to a {@link module:ui/button/buttonview~ButtonView} with + * {@link module:ui/button/buttonview~ButtonView#isOn} set `true`) or the very first item when none are active. * * See {@link module:ui/dropdown/utils~createDropdown} and {@link module:list/list~List}. * @@ -219,6 +239,56 @@ export function addListToDropdown( dropdownView, items ) { dropdownView.panelView.children.add( listView ); listView.items.delegate( 'execute' ).to( dropdownView ); + + // Accessibility: Focus the first active button in the list when the dropdown gets open. + focusChildOnDropdownOpen( dropdownView, () => listView.items.find( item => { + if ( item instanceof ListItemView ) { + return item.children.first.isOn; + } + + return false; + } ) ); +} + +/** + * A helper to be used on an existing {@link module:ui/dropdown/dropdownview~DropdownView} that focuses + * a specific child in DOM when the dropdown {@link module:ui/dropdown/dropdownview~DropdownView#isOpen gets open}. + * + * @param {module:ui/dropdown/dropdownview~DropdownView} dropdownView A dropdown instance to which the focus behavior will be added. + * @param {Function} childSelectorCallback A callback executed when the dropdown gets open. It should return a {@link module:ui/view~View} + * instance (child of {@link module:ui/dropdown/dropdownview~DropdownView#panelView}) that will get focused or a falsy value. + * If falsy value is returned, a default behavior of the dropdown will engage focusing the first focusable child in + * the {@link module:ui/dropdown/dropdownview~DropdownView#panelView}. + */ +export function focusChildOnDropdownOpen( dropdownView, childSelectorCallback ) { + dropdownView.on( 'change:isOpen', () => { + if ( !dropdownView.isOpen ) { + return; + } + + const childToFocus = childSelectorCallback(); + + if ( !childToFocus ) { + return; + } + + if ( typeof childToFocus.focus === 'function' ) { + childToFocus.focus(); + } else { + /** + * The child view of a {@link module:ui/dropdown/dropdownview~DropdownView dropdown} is missing the `focus()` method + * and could not be focused when the dropdown got {@link module:ui/dropdown/dropdownview~DropdownView#isOpen open}. + * + * Making the content of a dropdown focusable in this case greatly improves the accessibility. Please make the view instance + * implements the {@link module:ui/dropdown/dropdownpanelfocusable~DropdownPanelFocusable focusable interface} for the best user + * experience. + * + * @error ui-dropdown-focus-child-on-open-child-missing-focus + * @param {module:ui/view~View} view + */ + logWarning( 'ui-dropdown-focus-child-on-open-child-missing-focus', { view: childToFocus } ); + } + }, { priority: 'low' } ); } // Add a set of default behaviors to dropdown view. diff --git a/packages/ckeditor5-ui/tests/button/buttonview.js b/packages/ckeditor5-ui/tests/button/buttonview.js index 0b0d219e6d3..1b9db79712d 100644 --- a/packages/ckeditor5-ui/tests/button/buttonview.js +++ b/packages/ckeditor5-ui/tests/button/buttonview.js @@ -281,10 +281,10 @@ describe( 'ButtonView', () => { } ); describe( 'mousedown event', () => { - it( 'should be prevented', () => { + it( 'should not be prevented', () => { const ret = view.element.dispatchEvent( new Event( 'mousedown', { cancelable: true } ) ); - expect( ret ).to.false; + expect( ret ).to.true; } ); } ); diff --git a/packages/ckeditor5-ui/tests/dropdown/dropdownpanelview.js b/packages/ckeditor5-ui/tests/dropdown/dropdownpanelview.js index 962a1b4336c..64e16fde11a 100644 --- a/packages/ckeditor5-ui/tests/dropdown/dropdownpanelview.js +++ b/packages/ckeditor5-ui/tests/dropdown/dropdownpanelview.js @@ -3,11 +3,12 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* global Event */ +/* global Event, console */ import ViewCollection from '../../src/viewcollection'; import DropdownPanelView from '../../src/dropdown/dropdownpanelview'; import View from '../../src/view'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; describe( 'DropdownPanelView', () => { let view, locale; @@ -91,6 +92,34 @@ describe( 'DropdownPanelView', () => { sinon.assert.calledOnce( firstChildView.focus ); } ); + + describe( 'should warn', () => { + beforeEach( () => { + testUtils.sinon.stub( console, 'warn' ); + } ); + + afterEach( () => { + console.warn.restore(); + } ); + + it( 'if the view does not implement the focus() method', () => { + const firstChildView = new View(); + + firstChildView.focus = undefined; + + view.children.add( firstChildView ); + + view.focus(); + + sinon.assert.calledOnce( console.warn ); + sinon.assert.calledWithExactly( + console.warn, + 'ui-dropdown-panel-focus-child-missing-focus', + { childView: firstChildView, dropdownPanel: view }, + sinon.match.string + ); + } ); + } ); } ); describe( 'focusLast()', () => { diff --git a/packages/ckeditor5-ui/tests/dropdown/dropdownview.js b/packages/ckeditor5-ui/tests/dropdown/dropdownview.js index 9f202d82ddf..8d7ee2080a4 100644 --- a/packages/ckeditor5-ui/tests/dropdown/dropdownview.js +++ b/packages/ckeditor5-ui/tests/dropdown/dropdownview.js @@ -104,6 +104,26 @@ describe( 'DropdownView', () => { } ); } ); + describe( 'view#isOpen to view.panelView#focus', () => { + it( 'gets called upon opening', () => { + const spy = sinon.spy( view.panelView, 'focus' ); + + view.isOpen = true; + + expect( spy.callCount ).to.equal( 1 ); + } ); + + it( 'does not get called upon closing', () => { + view.isOpen = true; + + const spy = sinon.spy( view.panelView, 'focus' ); + + view.isOpen = false; + + expect( spy.callCount ).to.equal( 0 ); + } ); + } ); + describe( 'view.panelView#isVisible to view#isOpen', () => { it( 'is activated', () => { const values = []; diff --git a/packages/ckeditor5-ui/tests/dropdown/utils.js b/packages/ckeditor5-ui/tests/dropdown/utils.js index aaae6475701..748ae048868 100644 --- a/packages/ckeditor5-ui/tests/dropdown/utils.js +++ b/packages/ckeditor5-ui/tests/dropdown/utils.js @@ -3,11 +3,12 @@ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ -/* globals document Event */ +/* globals document, Event, console */ import { assertBinding } from '@ckeditor/ckeditor5-utils/tests/_utils/utils'; import { keyCodes } from '@ckeditor/ckeditor5-utils/src/keyboard'; import Collection from '@ckeditor/ckeditor5-utils/src/collection'; +import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils'; import Model from '../../src/model'; @@ -212,11 +213,21 @@ describe( 'utils', () => { dropdownView.isOpen = false; dropdownView.keystrokes.press( keyEvtData ); - sinon.assert.notCalled( spy ); + sinon.assert.calledOnce( spy ); + } ); + + it( '"arrowdown" focuses the #innerPanelView if dropdown was already open', () => { + const keyEvtData = { + keyCode: keyCodes.arrowdown, + preventDefault: sinon.spy(), + stopPropagation: sinon.spy() + }; dropdownView.isOpen = true; - dropdownView.keystrokes.press( keyEvtData ); + const spy = sinon.spy( panelChildView, 'focus' ); + + dropdownView.keystrokes.press( keyEvtData ); sinon.assert.calledOnce( spy ); } ); @@ -296,6 +307,58 @@ describe( 'utils', () => { dropdownView.toolbarView.items.first.fire( 'execute' ); } ); } ); + + describe( 'focus management on dropdown open', () => { + let buttons, dropdownView; + + beforeEach( () => { + buttons = [ 'foo', 'bar' ].map( icon => { + const button = new ButtonView(); + + button.icon = icon; + + return button; + } ); + + dropdownView = createDropdown( locale ); + + addToolbarToDropdown( dropdownView, buttons, { enableActiveItemFocusOnDropdownOpen: true } ); + + dropdownView.render(); + document.body.appendChild( dropdownView.element ); + } ); + + afterEach( () => { + dropdownView.element.remove(); + } ); + + it( 'focuses active item upon dropdown opening', () => { + dropdownView.toolbarView.items.get( 0 ).isOn = true; + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( dropdownView.toolbarView.items.get( 0 ).element ); + } ); + + it( 'focuses nth active item upon dropdown opening', () => { + dropdownView.toolbarView.items.get( 1 ).isOn = true; + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( dropdownView.toolbarView.items.get( 1 ).element ); + } ); + + it( 'focuses the first item if multiple items are active', () => { + dropdownView.toolbarView.items.get( 0 ).isOn = true; + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( dropdownView.toolbarView.items.get( 0 ).element ); + } ); + } ); } ); describe( 'addListToDropdown()', () => { @@ -460,5 +523,128 @@ describe( 'utils', () => { } ); } ); } ); + + describe( 'focus management on dropdown open', () => { + it( 'focuses active item upon dropdown opening', () => { + definitions.addMany( [ + { + type: 'button', + model: new Model( { label: 'a', isOn: true } ) + }, + { + type: 'button', + model: new Model( { label: 'b' } ) + } + ] ); + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( getListViewDomButton( listItems.get( 0 ) ) ); + } ); + + it( 'focuses nth active item upon dropdown opening', () => { + definitions.addMany( [ + { + type: 'button', + model: new Model( { label: 'a' } ) + }, + { + type: 'button', + model: new Model( { label: 'b', isOn: true } ) + } + ] ); + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( getListViewDomButton( listItems.get( 1 ) ) ); + } ); + + it( 'does not break for separator - still focuses nth active item upon dropdown opening', () => { + definitions.addMany( [ + { + type: 'button', + model: new Model( { label: 'a' } ) + }, + { + type: 'separator' + }, + { + type: 'button', + model: new Model( { label: 'b', isOn: true } ) + } + ] ); + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( getListViewDomButton( listItems.get( 2 ) ) ); + } ); + + it( 'focuses the first item if multiple items are active', () => { + definitions.addMany( [ + { + type: 'button', + model: new Model( { label: 'a' } ) + }, + { + type: 'button', + model: new Model( { label: 'b', isOn: true } ) + }, + { + type: 'button', + model: new Model( { label: 'c', isOn: true } ) + } + ] ); + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + expect( document.activeElement ).to.equal( getListViewDomButton( listItems.get( 1 ) ) ); + } ); + + describe( 'should warn', () => { + beforeEach( () => { + testUtils.sinon.stub( console, 'warn' ); + } ); + + afterEach( () => { + console.warn.restore(); + } ); + + it( 'if the active view does not implement the focus() method and therefore cannot be focused', () => { + definitions.addMany( [ + { + type: 'button', + model: new Model( { label: 'a' } ) + }, + { + type: 'button', + model: new Model( { label: 'b', isOn: true } ) + } + ] ); + + const secondChildView = dropdownView.listView.items.get( 1 ); + + secondChildView.focus = undefined; + + // The focus logic happens when the dropdown is opened. + dropdownView.isOpen = true; + + sinon.assert.calledOnce( console.warn ); + sinon.assert.calledWithExactly( + console.warn, + 'ui-dropdown-focus-child-on-open-child-missing-focus', + { view: secondChildView }, + sinon.match.string + ); + } ); + } ); + + function getListViewDomButton( listView ) { + return listView.children.first.element; + } + } ); } ); } );