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 d8a81c6288c..eb3aeca4eb1 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 @@ -19,6 +19,10 @@ cursor: default; min-width: 12em; + &.ck-list__item_selected { + background: var(--ck-color-list-button-hover-background); + } + & .ck-button { min-height: unset; width: 100%; diff --git a/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts index fde873ce699..b580e4296d7 100644 --- a/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts +++ b/packages/ckeditor5-ui/src/autocomplete/autocompleteview.ts @@ -11,7 +11,7 @@ import { getOptimalPosition, type PositioningFunction, type Locale, global, toUn 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 type { FilteredViewExecuteEvent, FilteredViewSelectEvent } from '../search/filteredview'; import '../../theme/components/autocomplete/autocomplete.css'; @@ -83,6 +83,7 @@ export default class AutocompleteView< this.on( 'search', () => { this._updateResultsVisibility(); this._updateResultsViewWidthAndPosition(); + this.filteredView.resetSelect(); } ); // Hide the results view when the user presses the ESC key. @@ -91,6 +92,35 @@ export default class AutocompleteView< cancel(); } ); + this.keystrokes.set( 'arrowdown', ( evt, cancel ) => { + if ( this.resultsView.isVisible ) { + this.filteredView.selectNext(); + } else { + this.resultsView.isVisible = true; + this.search( this.queryView.fieldView.element!.value ); + } + + cancel(); + } ); + + this.keystrokes.set( 'arrowup', ( evt, cancel ) => { + if ( this.resultsView.isVisible ) { + this.filteredView.selectPrevious(); + } else { + this.resultsView.isVisible = true; + this.search( this.queryView.fieldView.element!.value ); + } + + cancel(); + } ); + + this.keystrokes.set( 'enter', ( evt, cancel ) => { + if ( this.resultsView.isVisible ) { + 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', () => { @@ -120,9 +150,18 @@ export default class AutocompleteView< this.resultsView.isVisible = false; } ); + this.filteredView.on( 'select', ( evt, { selectedValue } ) => { + // Update the value of the query field. + this.queryView.fieldView.value = this.queryView.fieldView.element!.value = selectedValue; + } ); + // Update the position and width of the results view when it becomes visible. - this.resultsView.on( 'change:isVisible', () => { + this.resultsView.on( 'change:isVisible', ( evt, name, isVisible ) => { this._updateResultsViewWidthAndPosition(); + + if ( !isVisible ) { + this.filteredView.resetSelect(); + } } ); } diff --git a/packages/ckeditor5-ui/src/list/listitemview.ts b/packages/ckeditor5-ui/src/list/listitemview.ts index 70b21b462ce..7e52b76fac2 100644 --- a/packages/ckeditor5-ui/src/list/listitemview.ts +++ b/packages/ckeditor5-ui/src/list/listitemview.ts @@ -32,6 +32,11 @@ export default class ListItemView extends View { */ declare public isVisible: boolean; + /** + * @inheritDoc + */ + declare public isSelected: boolean; + /** * @inheritDoc */ @@ -41,6 +46,7 @@ export default class ListItemView extends View { const bind = this.bindTemplate; this.set( 'isVisible', true ); + this.set( 'isSelected', false ); this.children = this.createCollection(); @@ -51,7 +57,8 @@ export default class ListItemView extends View { class: [ 'ck', 'ck-list__item', - bind.if( 'isVisible', 'ck-hidden', value => !value ) + bind.if( 'isVisible', 'ck-hidden', value => !value ), + bind.if( 'isSelected', 'ck-list__item_selected' ) ], role: 'presentation' }, diff --git a/packages/ckeditor5-ui/src/list/listview.ts b/packages/ckeditor5-ui/src/list/listview.ts index 14fdc21810a..86623c2f312 100644 --- a/packages/ckeditor5-ui/src/list/listview.ts +++ b/packages/ckeditor5-ui/src/list/listview.ts @@ -10,7 +10,7 @@ import View from '../view'; import FocusCycler from '../focuscycler'; -import type ListItemView from './listitemview'; +import ListItemView from './listitemview'; import ListItemGroupView from './listitemgroupview'; import type DropdownPanelFocusable from '../dropdown/dropdownpanelfocusable'; import ViewCollection from '../viewcollection'; @@ -20,10 +20,12 @@ import { KeystrokeHandler, type Locale, type GetCallback, - type CollectionChangeEvent + type CollectionChangeEvent, + isVisible } from '@ckeditor/ckeditor5-utils'; import '../../theme/components/list/list.css'; +import ViewCycler from '../viewcycler'; /** * The list view class. @@ -34,7 +36,7 @@ export default class ListView extends View implements Dropdown * between the {@link module:ui/list/listitemview~ListItemView list items} and * {@link module:ui/list/listitemgroupview~ListItemGroupView list groups}. */ - public readonly focusables: ViewCollection; + public readonly focusables: ViewCollection; /** * Collection of the child list views. @@ -77,6 +79,11 @@ export default class ListView extends View implements Dropdown */ private readonly _focusCycler: FocusCycler; + /** + * TODO + */ + private readonly _viewCycler: ViewCycler; + /** * A cached map of {@link module:ui/list/listitemgroupview~ListItemGroupView} to `change` event listeners for their `items`. * Used for accessibility and keyboard navigation purposes. @@ -95,6 +102,11 @@ export default class ListView extends View implements Dropdown this.items = this.createCollection(); this.focusTracker = new FocusTracker(); this.keystrokes = new KeystrokeHandler(); + this._viewCycler = new ViewCycler( { + views: this.focusables, + viewsFilter: ( view: View ) => view instanceof ListItemView && isVisible( view.element ), + currentViewFilter: ( view: View ) => ( view as ListItemView ).isSelected + } ); this._focusCycler = new FocusCycler( { focusables: this.focusables, @@ -199,6 +211,61 @@ export default class ListView extends View implements Dropdown this._focusCycler.focusLast(); } + /** + * TODO + */ + public selectPrevious(): ListItemView | null { + const previousListItemView = this._viewCycler.previous! as ListItemView | null; + + if ( previousListItemView ) { + this.resetSelect(); + + previousListItemView.isSelected = true; + previousListItemView.element!.scrollIntoView( { block: 'nearest', inline: 'nearest' } ); + + this.fire( 'select', { + listItemView: previousListItemView + } ); + + return previousListItemView; + } + + return null; + } + + /** + * TODO + */ + public selectNext(): ListItemView | null { + const nextListItemView = this._viewCycler.next! as ListItemView | null; + + if ( nextListItemView ) { + this.resetSelect(); + + nextListItemView.isSelected = true; + nextListItemView.element!.scrollIntoView( { block: 'nearest', inline: 'nearest' } ); + + this.fire( 'select', { + listItemView: nextListItemView + } ); + + return nextListItemView; + } + + return null; + } + + /** + * TODO + */ + public resetSelect(): void { + const currentListItemView = this._viewCycler.current as ListItemView | null; + + if ( currentListItemView ) { + currentListItemView.isSelected = false; + } + } + /** * Registers a list item view in the focus tracker. * @@ -277,3 +344,15 @@ export default class ListView extends View implements Dropdown // There's no support for nested groups yet. type ListItemsChangeEvent = CollectionChangeEvent; + +/** + * TODO + * + * @eventName ~ListView#select + */ +export interface ListViewSelectEvent { + name: 'select'; + args: [ { + listItemView: ListItemView; + } ]; +} diff --git a/packages/ckeditor5-ui/src/search/filteredview.ts b/packages/ckeditor5-ui/src/search/filteredview.ts index c56a5023e61..f90e9a4f58a 100644 --- a/packages/ckeditor5-ui/src/search/filteredview.ts +++ b/packages/ckeditor5-ui/src/search/filteredview.ts @@ -21,10 +21,16 @@ export default interface FilteredView extends FocusableView { resultsCount: number; totalItemsCount: number; }; + + selectNext(): void; + + selectPrevious(): void; + + resetSelect(): void; } /** - * Fired when the user selects an autocomplete option. The event data should contain the selected value. + * Fired when the user picks an autocomplete option (e.g. by clicking on it). The event data should contain the selected value. * * @eventName ~FilteredView#execute */ @@ -32,3 +38,15 @@ export interface FilteredViewExecuteEvent { name: 'execute'; args: [ { value: string } ]; } + +/** + * Fired when the user selects an autocomplete option (e.g. by using arrow keys). The event data should contain the selected value. + * + * @eventName ~FilteredView#select + */ +export interface FilteredViewSelectEvent { + name: 'select'; + args: [ { + selectedValue: any; + } ]; +} diff --git a/packages/ckeditor5-ui/src/viewcycler.ts b/packages/ckeditor5-ui/src/viewcycler.ts new file mode 100644 index 00000000000..0f3e205638d --- /dev/null +++ b/packages/ckeditor5-ui/src/viewcycler.ts @@ -0,0 +1,131 @@ +/** + * @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/viewcycler + */ + +import { + isVisible, + EmitterMixin +} from '@ckeditor/ckeditor5-utils'; + +import type View from './view'; +import type ViewCollection from './viewcollection'; + +/** + * TODO + */ +export default class ViewCycler extends EmitterMixin() { + /** + * A {@link module:ui/view~View view} collection that the cycler operates on. + */ + public readonly views: ViewCollection; + + /** + * TODO + */ + public readonly viewsFilter: ( view: View ) => boolean; + + /** + * TODO + */ + public readonly currentViewFilter: ( view: View ) => boolean; + + /** + * Creates an instance of the focus cycler utility. + * + * @param options Configuration options. + */ + constructor( options: { + views: ViewCollection; + viewsFilter?: ( view: View ) => boolean; + currentViewFilter: ( view: View ) => boolean; + } ) { + super(); + + this.views = options.views; + this.viewsFilter = options.viewsFilter || ( ( view: View ) => isVisible( view.element ) ); + this.currentViewFilter = options.currentViewFilter; + } + + /** + * TODO + */ + public get first(): View | null { + return ( this.views.find( this.viewsFilter ) || null ) as View | null; + } + + /** + * TODO + */ + public get last(): View | null { + return ( this.views.filter( this.viewsFilter ).slice( -1 )[ 0 ] || null ) as View | null; + } + + /** + * TODO + */ + public get next(): View | null { + return this._getView( 1 ); + } + + /** + * TODO + */ + public get previous(): View | null { + return this._getView( -1 ); + } + + /** + * TODO + */ + public get current(): View | null { + for ( const view of this.views ) { + if ( this.currentViewFilter( view ) ) { + return view; + } + } + + return null; + } + + /** + * TODO + * + * @param step Either `1` for checking forward from {@link #current} or `-1` for checking backwards. + */ + private _getView( step: 1 | -1 ): View | null { + // Cache for speed. + const current = this.current; + const collectionLength = this.views.length; + + if ( !collectionLength ) { + return null; + } + + // Start from the beginning if no view is focused. + // https://github.com/ckeditor/ckeditor5-ui/issues/206 + if ( current === null ) { + return this[ step === 1 ? 'first' : 'last' ]; + } + + // Cycle in both directions. + let index = ( this.views.getIndex( current ) + collectionLength + step ) % collectionLength; + + do { + const view = this.views.get( index )!; + + if ( this.viewsFilter( view ) ) { + return view; + } + + // Cycle in both directions. + index = ( index + collectionLength + step ) % collectionLength; + } while ( index !== this.views.getIndex( current ) ); + + return null; + } +} diff --git a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts index a2567f1f32e..01c69b92f5b 100644 --- a/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts +++ b/packages/ckeditor5-ui/tests/manual/autocomplete/autocomplete.ts @@ -12,24 +12,38 @@ import { ListView, AutocompleteView, type FilteredView, - type FilteredViewExecuteEvent + type FilteredViewExecuteEvent, + ListItemGroupView } from '../../../src'; +import type { ListViewSelectEvent } from '../../../src/list/listview'; +import type { FilteredViewSelectEvent } from '../../../src/search/filteredview'; const locale = new Locale(); class FilteredTestListView extends ListView implements FilteredView { + constructor( locale: Locale ) { + super( locale ); + + this.on( 'select', ( evt, data ) => { + data.selectedValue = ( data.listItemView.children.first! as ButtonView ).label; + } ); + } + public filter( query ) { let visibleItems = 0; - for ( const item of this.items ) { - const listItemView = ( item as ListItemView ); - const buttonView = listItemView.children.first! as ButtonView; + for ( const groupView of this.items ) { + for ( const listItemView of ( groupView as ListItemGroupView ).items ) { + const buttonView = listItemView.children.first! as ButtonView; - listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; + listItemView.isVisible = query ? !!buttonView.label!.match( query ) : true; - if ( listItemView.isVisible ) { - visibleItems++; + if ( listItemView.isVisible ) { + visibleItems++; + } } + + groupView.isVisible = !!( groupView as ListItemGroupView ).items.filter( listItemView => listItemView.isVisible ).length; } return { @@ -39,29 +53,57 @@ class FilteredTestListView extends ListView implements FilteredView { } } -const listView = new FilteredTestListView(); - -[ - 'getAttribute()', 'getAttributeNames()', 'getAttributeNode()', 'getAttributeNodeNS()', 'getAttributeNS()', - 'getBoundingClientRect()', 'getClientRects()', 'getElementsByClassName()', 'getElementsByTagName()', 'getElementsByTagNameNS()', - 'hasAttribute()', 'hasAttributeNS()', 'hasAttributes()', 'hasPointerCapture()', 'insertAdjacentElement()', 'insertAdjacentHTML()', - 'insertAdjacentText()', 'matches()', 'prepend()', 'querySelector()', 'querySelectorAll()', 'releasePointerCapture()', 'remove()', - 'removeAttribute()', 'removeAttributeNode()', 'removeAttributeNS()' -].forEach( item => { - const listItemView = new ListItemView(); - const buttonView = new ButtonView(); - - buttonView.on( 'execute', () => { - listView.fire( 'execute', { - value: buttonView.label! +const listView = new FilteredTestListView( locale ); + +/* eslint-disable max-len */ +const groupedCountries = [ 'Albania', 'Andorra', 'Austria', 'Belarus', 'Belgium', 'Bosnia and Herzegovina', 'Bulgaria', 'Croatia', 'Czech Republic (Czechia)', 'Denmark', 'Estonia', 'Finland', 'France', 'Germany', 'Greece', 'Holy See', 'Hungary', 'Iceland', 'Ireland', 'Italy', 'Latvia', 'Liechtenstein', 'Lithuania', 'Luxembourg', 'Malta', 'Moldova', 'Monaco', 'Montenegro', 'Netherlands', 'North Macedonia', 'Norway', 'Poland', 'Portugal', 'Romania', 'Russia', 'San Marino', 'Serbia', 'Slovakia', 'Slovenia', 'Spain', 'Sweden', 'Switzerland', 'Ukraine', 'United Kingdom' ] + .reduce( ( acc, current ) => { + const firstLetter = current[ 0 ].toUpperCase(); + + if ( !acc[ firstLetter ] ) { + acc[ firstLetter ] = []; + } + + acc[ firstLetter ].push( current ); + + return acc; + }, {} ); + +for ( const groupName in groupedCountries ) { + const countryViews = groupedCountries[ groupName ].map( countryName => { + const listItemView = createItem( countryName ); + + listItemView.children.first!.on( 'execute', () => { + listView.fire( 'execute', { + value: countryName + } ); } ); + + return listItemView; } ); - buttonView.withText = true; - buttonView.label = item; - listItemView.children.add( buttonView ); - listView.items.add( listItemView ); -} ); + listView.items.add( createGroup( groupName, countryViews ) ); +} + +function createItem( label: string ): ListItemView { + const item = new ListItemView(); + const button = new ButtonView(); + + item.children.add( button ); + + button.set( { label, withText: true } ); + + return item; +} + +function createGroup( label: string, items: Array ): ListItemGroupView { + const groupView = new ListItemGroupView(); + + groupView.label = label; + groupView.items.addMany( items ); + + return groupView; +} const view = new AutocompleteView( locale, { queryView: {