From 16a3604fb62c20c25c4e62effc0872bde5f84d5e Mon Sep 17 00:00:00 2001 From: rfiorentino1 Date: Sun, 11 Jan 2026 16:32:03 -0600 Subject: [PATCH 1/3] a11y: improve settings pages and layout --- ui/src/app/core/settings.service.ts | 106 +-- .../settings/backup/backup.component.html | 27 +- .../modules/settings/settings.component.ts | 84 ++- .../layout/sidebar/sidebar.component.html | 612 ++++++++++++++---- .../layout/sidebar/sidebar.component.ts | 167 +++-- 5 files changed, 738 insertions(+), 258 deletions(-) diff --git a/ui/src/app/core/settings.service.ts b/ui/src/app/core/settings.service.ts index fe7604edd9..4ffcb8e611 100644 --- a/ui/src/app/core/settings.service.ts +++ b/ui/src/app/core/settings.service.ts @@ -39,8 +39,8 @@ export class SettingsService { public keepOrphans: boolean public wallpaper: string public serverTimeOffset = 0 - public rtl = false // set true if current translation is RLT - public browserLang: string // set by the browser language + public rtl = false + public browserLang: string public onSettingsLoaded = this.settingsLoadedSubject.pipe(first()) public settingsLoaded = false public readonly themeList = [ @@ -104,24 +104,28 @@ export class SettingsService { } } + private getIframeOrigin(iframe: HTMLIFrameElement): string { + try { + const src = iframe.getAttribute('src') || '' + const url = new URL(src, window.location.href) + return url.origin + } catch { + return '' + } + } + public setTheme(theme: string) { - // Default theme is deep-purple if (!theme || !this.themeList.includes(theme)) { theme = this.defaultTheme - - // Save the new property to the config file firstValueFrom(this.$api.put('/config-editor/ui', { key: 'theme', value: theme })) .catch(error => console.error('Error saving setTheme:', error)) } - // Grab the body element const bodySelector = window.document.querySelector('body') - // Remove all existing theme classes bodySelector.classList.remove(`config-ui-x-${this.theme}`) bodySelector.classList.remove(`config-ui-x-dark-mode-${this.theme}`) - // Set the new theme this.theme = theme if (this.actualLightingMode === 'dark') { bodySelector.classList.add(`config-ui-x-dark-mode-${this.theme}`) @@ -135,50 +139,59 @@ export class SettingsService { } } - // Update same-origin iframes - const iframes = window.document.querySelectorAll('iframe') + const iframes = window.document.querySelectorAll('iframe') as NodeListOf iframes.forEach((iframe, index) => { - try { - const iframeDoc = iframe.contentDocument - if (iframeDoc) { - const iframeBody = iframeDoc.body + const iframeOrigin = this.getIframeOrigin(iframe) + const sameOrigin = !!iframeOrigin && iframeOrigin === window.location.origin - if (this.actualLightingMode === 'dark') { - if (iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { - iframeBody.classList.remove(`config-ui-x-${this.theme}`) - } - if (!iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { - iframeBody.classList.add(`config-ui-x-dark-mode-${this.theme}`) - } - if (!iframeBody.classList.contains('dark-mode')) { - iframeBody.classList.add('dark-mode') - } + if (sameOrigin) { + try { + const iframeDoc = iframe.contentDocument + const iframeBody = iframeDoc?.body - iframeBody.style.backgroundColor = '#242424 !important' - iframeBody.style.color = '#ffffff !important' - } else { - if (!iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { - iframeBody.classList.add(`config-ui-x-${this.theme}`) - } - if (iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { - iframeBody.classList.remove(`config-ui-x-dark-mode-${this.theme}`) - } - if (iframeBody.classList.contains('dark-mode')) { - iframeBody.classList.remove('dark-mode') - } + if (iframeBody) { + if (this.actualLightingMode === 'dark') { + if (iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { + iframeBody.classList.remove(`config-ui-x-${this.theme}`) + } + if (!iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { + iframeBody.classList.add(`config-ui-x-dark-mode-${this.theme}`) + } + if (!iframeBody.classList.contains('dark-mode')) { + iframeBody.classList.add('dark-mode') + } + + iframeBody.style.backgroundColor = '#242424 !important' + iframeBody.style.color = '#ffffff !important' + } else { + if (!iframeBody.classList.contains(`config-ui-x-${this.theme}`)) { + iframeBody.classList.add(`config-ui-x-${this.theme}`) + } + if (iframeBody.classList.contains(`config-ui-x-dark-mode-${this.theme}`)) { + iframeBody.classList.remove(`config-ui-x-dark-mode-${this.theme}`) + } + if (iframeBody.classList.contains('dark-mode')) { + iframeBody.classList.remove('dark-mode') + } - iframeBody.style.backgroundColor = '#ffffff !important' - iframeBody.style.color = '#000000 !important' + iframeBody.style.backgroundColor = '#ffffff !important' + iframeBody.style.color = '#000000 !important' + } } + } catch (e) { + console.warn(`Iframe ${index}: Same-origin access failed`, { error: e, src: iframe.src }) + } + } - // Notify iframe Angular app + try { + if (iframe.contentWindow) { iframe.contentWindow.postMessage( { type: 'theme-update', isDark: this.actualLightingMode === 'dark', theme }, - window.location.origin, + iframeOrigin || '*', ) } } catch (e) { - console.warn(`Iframe ${index}: Access denied (cross-origin?)`, { error: e, src: iframe.src }) + console.warn(`Iframe ${index}: postMessage failed`, { error: e, src: iframe.src }) } }) } @@ -205,7 +218,6 @@ export class SettingsService { } public setEnvItem(key: string, value: any) { - // If the key contains a dot, we assume it's a nested property if (key.includes('.')) { const keys = key.split('.') let current = this.env @@ -226,11 +238,6 @@ export class SettingsService { } } - /** - * Check to make sure the server time is roughly the same as the client time. - * A warning is shown if the time difference is >= 4 hours. - * @param timestamp - */ private checkServerTime(timestamp: string) { const serverTime = dayjs(timestamp) const diff = serverTime.diff(dayjs(), 'hour') @@ -269,11 +276,6 @@ export class SettingsService { } } - /** - * Check if a specific feature is enabled based on feature flags - * @param featureKey The feature flag key to check - * @returns true if the feature is enabled, false otherwise - */ public isFeatureEnabled(featureKey: string): boolean { return this.env.featureFlags?.[featureKey] ?? false } diff --git a/ui/src/app/modules/settings/backup/backup.component.html b/ui/src/app/modules/settings/backup/backup.component.html index c7d78d7cb2..b45b1c6641 100644 --- a/ui/src/app/modules/settings/backup/backup.component.html +++ b/ui/src/app/modules/settings/backup/backup.component.html @@ -71,7 +71,12 @@
{{ 'backup.settings_title' | translate }}
[disabled]="clicked" [attr.aria-label]="'backup.backup_now' | translate" > - + +
  • @@ -86,7 +91,12 @@
    {{ 'backup.settings_title' | translate }}
    [disabled]="clicked" [attr.aria-label]="'backup.backup_now' | translate" > - + +
  • @@ -101,7 +111,8 @@
    {{ 'backup.settings_title' | translate }}
    [attr.aria-label]="'backup.restore_now' | translate" [disabled]="clicked" > - + +
  • @@ -126,7 +137,7 @@
    {{ 'backup.files_auto' | translate }}
    triggers="hover" class="red-text" > - + {{ backup.size }}MB } @if (backup.size <= backup.maxBackupSize) { @@ -146,7 +157,8 @@
    {{ 'backup.files_auto' | translate }}
    triggers="hover" [attr.aria-label]="'form.button_restore' | translate" > - + + diff --git a/ui/src/app/modules/settings/settings.component.ts b/ui/src/app/modules/settings/settings.component.ts index b110cb8aca..75f863cef7 100644 --- a/ui/src/app/modules/settings/settings.component.ts +++ b/ui/src/app/modules/settings/settings.component.ts @@ -1765,26 +1765,68 @@ export class SettingsComponent implements OnInit { }) } - private showRestartToast() { - if (!this.restartToastIsShown) { - this.restartToastIsShown = true - this.$settings.restartToastRef = this.$toastr.info( - this.$translate.instant('settings.changes.saved'), - this.$translate.instant('menu.hbrestart.title'), - { - timeOut: 0, - tapToDismiss: true, - disableTimeOut: true, - positionClass: 'toast-bottom-right', - enableHtml: true, - }, - ) - - if (this.$settings.restartToastRef && this.$settings.restartToastRef.onTap) { - this.$settings.restartToastRef.onTap.subscribe(() => { - void this.$router.navigate(['/restart']) - }) +private showRestartToast() { + if (this.restartToastIsShown) { + return + } + this.restartToastIsShown = true + const msg = this.$translate.instant('settings.changes.saved') + const html = ` +
    +

    ${msg}

    +
    + ` + const ref = this.$toastr.info(html, '', { + timeOut: 0, + disableTimeOut: true, + enableHtml: true, + tapToDismiss: false, + closeButton: true, + positionClass: 'toast-bottom-right', + toastClass: 'ngx-toastr hb-restart-toast-toast', + }) + this.$settings.restartToastRef = ref + ref.onShown.subscribe(() => { + const container = document.getElementById('toast-container') + if (!container) return + const toastEl = + container.querySelector(`#toast-${ref.toastId}`) + || container + toastEl.setAttribute('role', 'alert') + toastEl.setAttribute('aria-live', 'assertive') + toastEl.setAttribute('aria-atomic', 'true') + const body = toastEl.querySelector('.toast-message') + const closeBtn = toastEl.querySelector('.toast-close-button') + if (!body || !closeBtn) return + closeBtn.setAttribute('type', 'button') + closeBtn.setAttribute('aria-label', 'Close') + const msgEl = body.querySelector('.hb-restart-toast-msg') + if (!msgEl) return + const restartBtn = document.createElement('button') + restartBtn.type = 'button' + restartBtn.className = closeBtn.className + restartBtn.textContent = 'Restart Homebridge' + // Remove the aria-label - textContent already provides the accessible name + const activate = (ev: Event) => { + ev.preventDefault() + ev.stopPropagation() + void this.$router.navigate(['/restart']) + this.$toastr.clear(ref.toastId) + } + restartBtn.addEventListener('click', activate) + restartBtn.addEventListener('keydown', (ev: KeyboardEvent) => { + if (ev.key === 'Enter' || ev.key === ' ') { + activate(ev) } - } - } + }) + while (body.firstChild) { + body.removeChild(body.firstChild) + } + body.appendChild(msgEl) + body.appendChild(restartBtn) + }) + ref.onHidden.subscribe(() => { + this.restartToastIsShown = false + }) } +} \ No newline at end of file diff --git a/ui/src/app/shared/layout/sidebar/sidebar.component.html b/ui/src/app/shared/layout/sidebar/sidebar.component.html index 6262bb9a99..9dc4e0c16e 100644 --- a/ui/src/app/shared/layout/sidebar/sidebar.component.html +++ b/ui/src/app/shared/layout/sidebar/sidebar.component.html @@ -1,216 +1,584 @@ -