From b66cb448ec374989dca16ca0a603a85eae4d4754 Mon Sep 17 00:00:00 2001 From: redphx <96280+redphx@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:08:56 +0700 Subject: [PATCH] Make Stream settings dialog controller-friendly --- src/assets/css/global-settings.styl | 4 +- src/assets/css/number-stepper.styl | 2 +- src/assets/css/stream-settings.styl | 33 ++++- src/assets/css/web-components.styl | 7 +- src/modules/stream/stream-settings.ts | 203 ++++++++++++++++++++++++-- src/modules/ui/global-settings.ts | 21 ++- src/utils/html.ts | 2 + src/utils/settings.ts | 6 +- src/utils/translation.ts | 1 + src/web-components/bx-select.ts | 7 +- 10 files changed, 257 insertions(+), 29 deletions(-) diff --git a/src/assets/css/global-settings.styl b/src/assets/css/global-settings.styl index 7db0c2e..90cf3b4 100644 --- a/src/assets/css/global-settings.styl +++ b/src/assets/css/global-settings.styl @@ -17,11 +17,13 @@ } .bx-settings-wrapper { - width: 450px; + min-width: 450px; + max-width: 600px; margin: auto; padding: 12px 6px; @media screen and (max-width: 450px) { + min-width: unset; width: 100%; } diff --git a/src/assets/css/number-stepper.styl b/src/assets/css/number-stepper.styl index 136e5ef..9a1c6ec 100644 --- a/src/assets/css/number-stepper.styl +++ b/src/assets/css/number-stepper.styl @@ -5,7 +5,7 @@ display: inline-block; min-width: 40px; font-family: var(--bx-monospaced-font); - font-size: 14px; + font-size: 12px; } button { diff --git a/src/assets/css/stream-settings.styl b/src/assets/css/stream-settings.styl index 03ababe..eebb1f1 100644 --- a/src/assets/css/stream-settings.styl +++ b/src/assets/css/stream-settings.styl @@ -49,6 +49,11 @@ background: #2f2f2f; border-color: #484848; } + + &:focus { + border-color: #fff; + outline: none; + } } } @@ -70,13 +75,14 @@ box-shadow: 0px 0px 6px #000; overflow: overlay; - > div[data-group=mkb] { + > div[data-tab-group=mkb] { display: flex; flex-direction: column; height: 100%; overflow: hidden; } + &:focus, *:focus { outline: none !important; } @@ -106,8 +112,23 @@ .bx-stream-settings-row { display: flex; border-bottom: 1px solid #40404080; - margin-bottom: 16px; - padding-bottom: 16px; + padding: 16px 8px; + border-left: 2px solid transparent; + + &:hover, &:focus-within { + background-color: #242424; + } + + input[type=checkbox], + select { + &:focus { + filter: drop-shadow(1px 0 0 #fff) drop-shadow(-1px 0 0 #fff) drop-shadow(0 1px 0 #fff) drop-shadow(0 -1px 0 #fff); + } + } + + &:has(input:focus), &:has(select:focus), &:has(button:focus) { + border-left-color: white; + } > label { font-size: 16px; @@ -120,6 +141,10 @@ input { accent-color: var(--bx-primary-button-color); + + &:focus { + accent-color: var(--bx-danger-button-color); + } } select:disabled { @@ -143,7 +168,7 @@ } .bx-stream-settings-tab-contents { - div[data-group="shortcuts"] { + div[data-tab-group="shortcuts"] { > div { &[data-has-gamepad=true] { > div:first-of-type { diff --git a/src/assets/css/web-components.styl b/src/assets/css/web-components.styl index 4aa80e3..16a1ff6 100644 --- a/src/assets/css/web-components.styl +++ b/src/assets/css/web-components.styl @@ -73,10 +73,10 @@ button.bx-button { border: none; - height: 30px; + height: 24px; width: 24px; padding: 0; - line-height: 30px; + line-height: 24px; color: #fff; border-radius: 4px; font-weight: bold; @@ -85,7 +85,8 @@ &.bx-inactive { pointer-events: none; - opacity: 0.1; + opacity: 0.2; + background: transparent !important; } span { diff --git a/src/modules/stream/stream-settings.ts b/src/modules/stream/stream-settings.ts index 83b6176..862443e 100644 --- a/src/modules/stream/stream-settings.ts +++ b/src/modules/stream/stream-settings.ts @@ -14,6 +14,19 @@ import { StreamStats } from "./stream-stats"; import { BxSelectElement } from "@/web-components/bx-select"; import { onChangeVideoPlayerType, updateVideoPlayer } from "./stream-settings-utils"; +enum FocusDirection { + UP, + RIGHT, + DOWN, + LEFT, +} + +enum FocusContainer { + OUTSIDE, + TABS, + SETTINGS, +} + export class StreamSettings { private static instance: StreamSettings; @@ -26,6 +39,8 @@ export class StreamSettings { } private $container: HTMLElement | undefined; + private $tabs: HTMLElement | undefined; + private $settings: HTMLElement | undefined; private $overlay: HTMLElement | undefined; readonly SETTINGS_UI = [{ @@ -248,40 +263,207 @@ export class StreamSettings { const $container = this.$container!; // Select tab if (tabId) { - const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-group=${tabId}]`); + const $tab = $container.querySelector(`.bx-stream-settings-tabs svg[data-tab-group=${tabId}]`); $tab && $tab.dispatchEvent(new Event('click')); } + // Show overlay this.$overlay!.classList.remove('bx-gone'); this.$overlay!.dataset.isPlaying = STATES.isPlaying.toString(); + // Show dialog $container.classList.remove('bx-gone'); + // Lock scroll bar document.body.classList.add('bx-no-scroll'); + if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) { + // Focus the first visible setting + this.#focusDirection(FocusDirection.DOWN); + + // Add event listeners + $container.addEventListener('keydown', this); + } + BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_SHOWN); } hide() { + // Hide overlay this.$overlay!.classList.add('bx-gone'); + // Hide dialog this.$container!.classList.add('bx-gone'); - + // Show scroll bar document.body.classList.remove('bx-no-scroll'); + // Remove event listeners + this.$container!.removeEventListener('keydown', this); + BxEvent.dispatch(window, BxEvent.XCLOUD_DIALOG_DISMISSED); } + #handleTabsNavigation($focusing: HTMLElement, direction: FocusDirection) { + if (direction === FocusDirection.UP || direction === FocusDirection.DOWN) { + let $sibling = $focusing; + const siblingProperty = direction === FocusDirection.UP ? 'previousElementSibling' : 'nextElementSibling'; + + while ($sibling[siblingProperty]) { + $sibling = $sibling[siblingProperty] as HTMLElement; + $sibling && $sibling.focus(); + return; + } + } else if (direction === FocusDirection.RIGHT) { + this.#focusFirstVisibleSetting(); + } + } + + #handleSettingsNavigation($focusing: HTMLElement, direction: FocusDirection) { + // If current element's tabIndex property is not 0 + if ($focusing.tabIndex !== 0) { + // Find first visible setting + const $childSetting = $focusing.querySelector('div[data-tab-group]:not(.bx-gone) [tabindex="0"]:not(a)') as HTMLElement; + if ($childSetting) { + $childSetting.focus(); + return; + } + } + + // Current element is setting -> Find the next one + // Find parent + let $parent = $focusing.closest('.bx-stream-settings-row') || $focusing.closest('h2'); + + if (!$parent) { + return; + } + + // Find sibling setting + let $sibling = $parent; + if (direction === FocusDirection.UP || direction === FocusDirection.DOWN) { + const siblingProperty = direction === FocusDirection.UP ? 'previousElementSibling' : 'nextElementSibling'; + + while ($sibling[siblingProperty]) { + $sibling = $sibling[siblingProperty]; + const $childSetting = $sibling.querySelector('[tabindex="0"]:last-of-type') as HTMLElement; + if ($childSetting) { + $childSetting.focus(); + return; + } + } + } else if (direction === FocusDirection.LEFT || direction === FocusDirection.RIGHT) { + // Find all child elements with tabindex + const children = Array.from($parent.querySelectorAll('[tabindex="0"]')); + const index = children.indexOf($focusing); + let nextIndex; + if (direction === FocusDirection.LEFT) { + nextIndex = index - 1; + } else { + nextIndex = index + 1; + } + + nextIndex = Math.max(-1, Math.min(nextIndex, children.length - 1)); + if (nextIndex === -1) { + // Focus setting tabs + const $tab = this.$tabs!.querySelector('svg.bx-active') as HTMLElement; + $tab && $tab.focus(); + } else if (nextIndex !== index) { + (children[nextIndex] as HTMLElement).focus(); + } + } + } + + #focusFirstVisibleSetting() { + // Focus the first visible tab content + const $tab = this.$settings!.querySelector('div[data-tab-group]:not(.bx-gone)') as HTMLElement; + + if ($tab) { + // Focus on the first focusable setting + const $control = $tab.querySelector('[tabindex="0"]:not(a)') as HTMLElement; + if ($control) { + $control.focus(); + } else { + // Focus tab + $tab.focus(); + } + } + } + + #focusDirection(direction: FocusDirection) { + const $tabs = this.$tabs!; + const $settings = this.$settings!; + + // Get current focused element + let $focusing = document.activeElement as HTMLElement; + + let focusContainer = FocusContainer.OUTSIDE; + if ($focusing) { + if ($settings.contains($focusing)) { + focusContainer = FocusContainer.SETTINGS; + } else if ($tabs.contains($focusing)) { + focusContainer = FocusContainer.TABS; + } + } + + // If not focusing any element or the focused element is not inside the dialog + if (focusContainer === FocusContainer.OUTSIDE) { + this.#focusFirstVisibleSetting(); + return; + } else if (focusContainer === FocusContainer.SETTINGS) { + this.#handleSettingsNavigation($focusing, direction); + } else if (focusContainer === FocusContainer.TABS) { + this.#handleTabsNavigation($focusing, direction); + } + } + + handleEvent(event: Event) { + switch (event.type) { + case 'keydown': + const $target = event.target as HTMLElement; + const keyboardEvent = event as KeyboardEvent; + const keyCode = keyboardEvent.code; + + if (keyCode === 'ArrowUp' || keyCode === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + + this.#focusDirection(keyCode === 'ArrowUp' ? FocusDirection.UP : FocusDirection.DOWN); + } else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowRight') { + if (($target as any).type !== 'range') { + event.preventDefault(); + event.stopPropagation(); + + this.#focusDirection(keyCode === 'ArrowLeft' ? FocusDirection.LEFT : FocusDirection.RIGHT); + } + } else if (keyCode === 'Enter' || keyCode === 'Space') { + if ($target instanceof SVGElement) { + event.preventDefault(); + event.stopPropagation(); + + $target.dispatchEvent(new Event('click')); + } + } else if (keyCode === 'Escape') { + this.hide(); + } + break; + } + } + #setupDialog() { let $tabs: HTMLElement; let $settings: HTMLElement; - const $overlay = CE('div', {'class': 'bx-stream-settings-overlay bx-gone'}); + const $overlay = CE('div', {class: 'bx-stream-settings-overlay bx-gone'}); this.$overlay = $overlay; - const $container = CE('div', {'class': 'bx-stream-settings-dialog bx-gone'}, - $tabs = CE('div', {'class': 'bx-stream-settings-tabs'}), - $settings = CE('div', {'class': 'bx-stream-settings-tab-contents'}), + const $container = CE('div', {class: 'bx-stream-settings-dialog bx-gone'}, + $tabs = CE('div', {class: 'bx-stream-settings-tabs'}), + $settings = CE('div', { + class: 'bx-stream-settings-tab-contents', + tabindex: 10, + }), ); + this.$container = $container; + this.$tabs = $tabs; + this.$settings = $settings; // Close dialog when clicking on the overlay $overlay.addEventListener('click', e => { @@ -296,10 +478,12 @@ export class StreamSettings { } const $svg = createSvgIcon(settingTab.icon); + $svg.tabIndex = 0; + $svg.addEventListener('click', e => { // Switch tab for (const $child of Array.from($settings.children)) { - if ($child.getAttribute('data-group') === settingTab.group) { + if ($child.getAttribute('data-tab-group') === settingTab.group) { $child.classList.remove('bx-gone'); } else { $child.classList.add('bx-gone'); @@ -316,7 +500,7 @@ export class StreamSettings { $tabs.appendChild($svg); - const $group = CE('div', {'data-group': settingTab.group, 'class': 'bx-gone'}); + const $group = CE('div', {'data-tab-group': settingTab.group, 'class': 'bx-gone'}); for (const settingGroup of settingTab.items) { if (!settingGroup) { @@ -327,9 +511,10 @@ export class StreamSettings { CE('span', {}, settingGroup.label), settingGroup.help_url && createButton({ icon: BxIcon.QUESTION, - style: ButtonStyle.GHOST, + style: ButtonStyle.GHOST | ButtonStyle.FOCUSABLE, url: settingGroup.help_url, title: t('help'), + tabIndex: 0, }), )); if (settingGroup.note) { diff --git a/src/modules/ui/global-settings.ts b/src/modules/ui/global-settings.ts index f9418dd..4c133f6 100644 --- a/src/modules/ui/global-settings.ts +++ b/src/modules/ui/global-settings.ts @@ -203,6 +203,8 @@ export function setupSettingsUi() { $wrapper.appendChild($div); } + let localeSwitchingTimeout: number | null; + const onChange = async (e: Event) => { // Clear PatcherCache; PatcherCache.clear(); @@ -214,12 +216,17 @@ export function setupSettingsUi() { $btnHeaderSettings && $btnHeaderSettings.classList.add('bx-danger'); if ((e.target as HTMLElement).id === 'bx_setting_' + PrefKey.BETTER_XCLOUD_LOCALE) { - // Update locale - Translations.refreshCurrentLocale(); - await Translations.updateTranslations(); + if (getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) { + localeSwitchingTimeout && window.clearTimeout(localeSwitchingTimeout); + localeSwitchingTimeout = window.setTimeout(() => { + Translations.refreshCurrentLocale(); + Translations.updateTranslations(); + }, 1000); + } else { + // Update locale + Translations.refreshCurrentLocale(); + await Translations.updateTranslations(); - // Don't refresh the page when using controller-friendly UI - if (!getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) { $btnReload.textContent = t('settings-reloading'); $btnReload.click(); } @@ -390,9 +397,9 @@ export function setupSettingsUi() { if ($control instanceof HTMLSelectElement && getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) { // Controller-friendly + $select.removeAttribute('tabindex'); + const $btnPrev = createButton({ label: '<', style: ButtonStyle.FOCUSABLE, @@ -88,10 +91,10 @@ export class BxSelectElement { const disableNext = visibleIndex === $select.querySelectorAll('option').length - 1; $btnPrev.classList.toggle('bx-inactive', disablePrev); - disablePrev && document.activeElement === $btnPrev && $btnNext.focus(); + // disablePrev && document.activeElement === $btnPrev && $btnNext.focus(); $btnNext.classList.toggle('bx-inactive', disableNext); - disableNext && document.activeElement === $btnNext &&$btnPrev.focus(); + // disableNext && document.activeElement === $btnNext &&$btnPrev.focus(); } const normalizeIndex = (index: number): number => {