diff --git a/src/scss/_mixins.scss b/src/scss/_mixins.scss index 7c7ac5dd2..eeafa1562 100644 --- a/src/scss/_mixins.scss +++ b/src/scss/_mixins.scss @@ -129,9 +129,13 @@ width: $marker-dimension; } -@mixin focusable { +@mixin focusable($inset: false) { &:focus { - box-shadow: $focus-element-box-shadow; + @if $inset == false { + box-shadow: $focus-element-box-shadow; + } @else { + box-shadow: inset $focus-element-box-shadow; + } outline: none; &:not(.#{$prefix}-focus-visible) { diff --git a/src/scss/components/settings/_settings-panel-item.scss b/src/scss/components/settings/_settings-panel-item.scss index cfa29cd0e..d004a84c5 100644 --- a/src/scss/components/settings/_settings-panel-item.scss +++ b/src/scss/components/settings/_settings-panel-item.scss @@ -1,6 +1,8 @@ @import '../../variables'; %ui-settings-panel-item { + @include focusable(true); + line-height: 1.5em; padding: .5em .7em; white-space: nowrap; diff --git a/src/ts/UIFactory.ts b/src/ts/UIFactory.ts index 4b4fdd476..96772cfbe 100644 --- a/src/ts/UIFactory.ts +++ b/src/ts/UIFactory.ts @@ -270,7 +270,6 @@ function uiLayout(config: UIConfig) { opener: subtitleSettingsOpenButton, }), settingComponent: subtitleSelectBox, - role: 'menubar', container: settingsPanel, }); mainSettingsPanelPage.addComponent(subtitleSelectItem); diff --git a/src/ts/components/Component.ts b/src/ts/components/Component.ts index da7bdd5d1..325c9989d 100644 --- a/src/ts/components/Component.ts +++ b/src/ts/components/Component.ts @@ -93,6 +93,13 @@ export interface ViewModeChangedEventArgs extends NoArgs { mode: ViewMode; } +export interface ComponentFocusChangedEventArgs extends NoArgs { + /** + * True is the component is focused, else false. + */ + focused: boolean; +} + /** * The base class of the UI framework. * Each component must extend this class and optionally the config interface. @@ -208,6 +215,7 @@ export class Component { onHoverChanged: new EventDispatcher, ComponentHoverChangedEventArgs>(), onEnabled: new EventDispatcher, NoArgs>(), onDisabled: new EventDispatcher, NoArgs>(), + onFocusChanged: new EventDispatcher, ComponentFocusChangedEventArgs>(), }; /** @@ -273,6 +281,10 @@ export class Component { // Track the hovered state of the element this.getDomElement().on('mouseenter', () => this.onHoverChangedEvent(true)); this.getDomElement().on('mouseleave', () => this.onHoverChangedEvent(false)); + + // Track the focused state of the element + this.getDomElement().on('focusin', () => this.onFocusChangedEvent(true)); + this.getDomElement().on('focusout', () => this.onFocusChangedEvent(false)); } /** @@ -298,6 +310,10 @@ export class Component { 'role': this.config.role, }, this); + if (typeof this.config.tabIndex === 'number') { + element.attr('tabindex', this.config.tabIndex.toString()); + } + return element; } @@ -531,6 +547,10 @@ export class Component { this.componentEvents.onHoverChanged.dispatch(this, { hovered: hovered }); } + protected onFocusChangedEvent(focused: boolean): void { + this.componentEvents.onFocusChanged.dispatch(this, { focused: focused }); + } + /** * Gets the event that is fired when the component is showing. * See the detailed explanation on event architecture on the {@link #componentEvents events list}. @@ -582,4 +602,12 @@ export class Component { get onViewModeChanged(): Event, ViewModeChangedEventArgs> { return this.componentEvents.onViewModeChanged.getEvent(); } + + /** + * Gets the event that is fired when the component's focus-state is changing. + * @returns {Event, ComponentFocusChangedEventArgs>} + */ + get onFocusedChanged(): Event, ComponentFocusChangedEventArgs> { + return this.componentEvents.onFocusChanged.getEvent(); + } } \ No newline at end of file diff --git a/src/ts/components/Container.ts b/src/ts/components/Container.ts index d3f1eff25..54a69118c 100644 --- a/src/ts/components/Container.ts +++ b/src/ts/components/Container.ts @@ -129,6 +129,10 @@ export class Container extends Component 'aria-label': i18n.performLocalization(this.config.ariaLabel), }, this); + if (typeof this.config.tabIndex === 'number') { + containerElement.attr('tabindex', this.config.tabIndex.toString()); + } + // Create the inner container element (the inner
) that will contain the components let innerContainer = new DOM(this.config.tag, { 'class': this.prefixCss('container-wrapper'), diff --git a/src/ts/components/buttons/Button.ts b/src/ts/components/buttons/Button.ts index 4338e2397..2fb94c734 100644 --- a/src/ts/components/buttons/Button.ts +++ b/src/ts/components/buttons/Button.ts @@ -77,6 +77,10 @@ export class Button extends Component { this.onClickEvent(); }); + buttonElement.on('focusin focusout', (e) => { + e.stopPropagation(); + }); + return buttonElement; } diff --git a/src/ts/components/settings/DynamicSettingsPanelItem.ts b/src/ts/components/settings/DynamicSettingsPanelItem.ts index 98c5d20b0..676e3f22b 100644 --- a/src/ts/components/settings/DynamicSettingsPanelItem.ts +++ b/src/ts/components/settings/DynamicSettingsPanelItem.ts @@ -11,6 +11,7 @@ import { SubtitleSettingsLabel } from './subtitlesettings/SubtitleSettingsLabel' import { SettingsPanel } from './SettingsPanel'; import { SettingsPanelPageBackButton } from './SettingsPanelPageBackButton'; import { SubtitleSettingSelectBox } from './subtitlesettings/SubtitleSettingSelectBox'; +import { InteractiveSettingsPanelItem } from './InteractiveSettingsPanelItem'; /** * Configuration interface for a {@link DynamicSettingsPanelItem}. @@ -35,10 +36,10 @@ export interface DynamicSettingsPanelItemConfig extends SettingsPanelItemConfig /** * A dynamic settings panel item which can build a sub page with the items of a {@link ListSelector}. * The page will be dynamically added and removed from the {@link SettingsPanel}. -* + * * @category Components */ -export class DynamicSettingsPanelItem extends SettingsPanelItem { +export class DynamicSettingsPanelItem extends InteractiveSettingsPanelItem { private selectedOptionLabel: Label; protected settingComponent: ListSelector; @@ -63,6 +64,7 @@ export class DynamicSettingsPanelItem extends SettingsPanelItem { + this.onClick.subscribe(() => { this.displayItemsSubPage(); - }; - this.getDomElement().on('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - handleItemClick(); }); } diff --git a/src/ts/components/settings/InteractiveSettingsPanelItem.ts b/src/ts/components/settings/InteractiveSettingsPanelItem.ts new file mode 100644 index 000000000..d732dccb0 --- /dev/null +++ b/src/ts/components/settings/InteractiveSettingsPanelItem.ts @@ -0,0 +1,65 @@ +import { SettingsPanelItem, SettingsPanelItemConfig } from './SettingsPanelItem'; +import { Event, EventDispatcher, NoArgs } from '../../EventDispatcher'; +import { PlayerAPI } from 'bitmovin-player'; +import { UIInstanceManager } from '../../UIManager'; +import { getKeyMapForPlatform } from '../../spatialnavigation/getKeyMapForPlatform'; +import { Action } from '../../spatialnavigation/types'; + +/** + * A settings panel item that can be interacted with using the keyboard or mouse. + * Can be used when no interactive element is present as child item. + */ +export class InteractiveSettingsPanelItem extends SettingsPanelItem { + private events = { + onClick: new EventDispatcher, NoArgs>(), + }; + + constructor(config: Config) { + super(config); + } + + configure(player: PlayerAPI, uimanager: UIInstanceManager) { + super.configure(player, uimanager); + + const handleClickEvent = (event: UIEvent) => { + event.preventDefault(); + event.stopPropagation(); + this.onClickEvent(); + }; + + this.getDomElement().on('click touchend', handleClickEvent); + + // Listen to keyboard events and trigger the click event when a select key is detected + const handleKeyDown = (event: KeyboardEvent) => { + const action = getKeyMapForPlatform()[event.keyCode]; + const acceptedKeys = ['Enter', ' ']; + const acceptedCodes = ['Enter', 'Space']; + + if (action === Action.SELECT || acceptedKeys.includes(event.key) || acceptedCodes.includes(event.code)) { + handleClickEvent(event); + } + }; + + this.onFocusedChanged.subscribe((_, args) => { + if (args.focused) { + // Only listen to keyboard events when the element is focused + this.getDomElement().on('keydown', handleKeyDown); + } else { + // Unregister the keyboard event listener when the element loses focus + this.getDomElement().off('keydown', handleKeyDown); + } + }); + } + + protected onClickEvent() { + this.events.onClick.dispatch(this); + } + + /** + * Gets the event that is fired when the SettingsPanelItem is clicked. + * @returns {Event, NoArgs>} + */ + get onClick(): Event, NoArgs> { + return this.events.onClick.getEvent(); + } +} diff --git a/src/ts/components/settings/SettingsPanelPage.ts b/src/ts/components/settings/SettingsPanelPage.ts index e47b099f3..0a7250a09 100644 --- a/src/ts/components/settings/SettingsPanelPage.ts +++ b/src/ts/components/settings/SettingsPanelPage.ts @@ -4,6 +4,7 @@ import {UIInstanceManager} from '../../UIManager'; import {Event, EventDispatcher, NoArgs} from '../../EventDispatcher'; import { PlayerAPI } from 'bitmovin-player'; import { BrowserUtils } from '../../utils/BrowserUtils'; +import { InteractiveSettingsPanelItem } from './InteractiveSettingsPanelItem'; /** * Configuration interface for a {@link SettingsPanelPage} @@ -94,7 +95,11 @@ export class SettingsPanelPage extends Container { this.settingsPanelPageEvents.onActive.dispatch(this); // Disable focus for iOS and iPadOS 13. They open select boxes automatically on focus and we want to avoid that. if (activeItems.length > 0 && !BrowserUtils.isIOS && !(BrowserUtils.isMacIntel && BrowserUtils.isTouchSupported)) { - activeItems[0].getDomElement().focusToFirstInput(); + if (activeItems[0] instanceof InteractiveSettingsPanelItem) { + activeItems[0].getDomElement().get(0).focus(); + } else { + activeItems[0].getDomElement().focusToFirstInput(); + } } } diff --git a/src/ts/components/settings/SettingsPanelSelectOption.ts b/src/ts/components/settings/SettingsPanelSelectOption.ts index 4562144de..eea155d13 100644 --- a/src/ts/components/settings/SettingsPanelSelectOption.ts +++ b/src/ts/components/settings/SettingsPanelSelectOption.ts @@ -1,7 +1,8 @@ -import { SettingsPanelItem, SettingsPanelItemConfig } from './SettingsPanelItem'; +import { SettingsPanelItemConfig } from './SettingsPanelItem'; import { PlayerAPI } from 'bitmovin-player'; import { UIInstanceManager } from '../../UIManager'; import { ListSelector, ListSelectorConfig } from '../lists/ListSelector'; +import { InteractiveSettingsPanelItem } from './InteractiveSettingsPanelItem'; /** * Configuration interface for a {@link SettingsPanelSelectOption}. @@ -25,7 +26,7 @@ export interface SettingsPanelSelectOptionConfig extends SettingsPanelItemConfig * * @category Components */ -export class SettingsPanelSelectOption extends SettingsPanelItem { +export class SettingsPanelSelectOption extends InteractiveSettingsPanelItem { private settingsValue: string | undefined; protected settingComponent: ListSelector; @@ -37,6 +38,7 @@ export class SettingsPanelSelectOption extends SettingsPanelItem { + this.onClick.subscribe(() => { this.settingComponent.selectItem(this.settingsValue); - }; - this.getDomElement().on('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - handleItemClick(); }); // Initial state