mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(i18n): convert to opaque keys (#1848)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
export type {
|
||||
FlatTranslations,
|
||||
Locale,
|
||||
TranslationParams,
|
||||
Translations,
|
||||
Translator,
|
||||
} from '@videojs/core/i18n';
|
||||
export {
|
||||
@@ -10,10 +10,13 @@ export {
|
||||
getBrowserTranslations,
|
||||
getI18nTranslations,
|
||||
hasRegisteredLocale,
|
||||
isText,
|
||||
loadLocale,
|
||||
onI18nRegistryChange,
|
||||
registerI18n,
|
||||
resolveBrowserTranslationTarget,
|
||||
resolveText,
|
||||
resolveTranslation,
|
||||
shouldAttemptBrowserTranslation,
|
||||
translateText,
|
||||
} from '@videojs/core/i18n';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import {
|
||||
badge,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@videojs/skins/minimal/tailwind/video.tailwind';
|
||||
import { createTemplate } from '@videojs/utils/dom';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { renderText } from '../../i18n/render-text';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
@@ -140,14 +142,14 @@ function getTemplateHTML() {
|
||||
|
||||
<button commandfor="settings-menu" aria-labelledby="settings-label" class="${cn(button.base, button.subtle, button.icon, menu.settingsTrigger, 'media-button--settings')}">
|
||||
${renderIcon('gear', { class: cn(icon, menu.settingsIcon) })}
|
||||
<media-text id="settings-label" class="media-sr-only">Settings</media-text>
|
||||
${renderText(settingsText, { id: 'settings-label', class: 'media-sr-only' })}
|
||||
</button>
|
||||
<media-menu id="settings-menu" side="top" align="center" class="${menu.settings}">
|
||||
<media-menu-view class="${menu.rootView}">
|
||||
<div class="${menu.group}">
|
||||
<media-menu-item commandfor="settings-quality-menu" type="quality" data-setting="quality" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('switches', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -155,7 +157,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('speech', { class: icon })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.chevron) })}
|
||||
@@ -163,7 +165,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('speed', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -171,7 +173,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-captions-menu" type="captions" data-setting="captions" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('captions-off', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -183,7 +185,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-quality-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-quality-radio-group class="${menu.group}">
|
||||
@@ -205,7 +207,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-audio-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-audio-track-radio-group class="${menu.group}">
|
||||
@@ -223,7 +225,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-speed-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-playback-rate-radio-group class="${menu.group}">
|
||||
@@ -241,7 +243,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-captions-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-captions-radio-group class="${menu.group}">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
|
||||
import { renderIcon } from '@videojs/icons/render/minimal';
|
||||
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
|
||||
import { renderText } from '../../i18n/render-text';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinElement } from '../skin-element';
|
||||
import styles from './minimal-skin.css?inline';
|
||||
@@ -118,14 +120,14 @@ function getTemplateHTML() {
|
||||
|
||||
<button commandfor="settings-menu" aria-labelledby="settings-label" class="media-button media-button--subtle media-button--icon media-button--settings">
|
||||
${renderIcon('gear', { class: 'media-icon media-icon--settings' })}
|
||||
<media-text id="settings-label" class="media-sr-only">Settings</media-text>
|
||||
${renderText(settingsText, { id: 'settings-label', class: 'media-sr-only' })}
|
||||
</button>
|
||||
<media-menu id="settings-menu" side="top" align="center" class="media-popover media-menu media-menu--settings">
|
||||
<media-menu-view class="media-menu__panel">
|
||||
<div class="media-menu__group">
|
||||
<media-menu-item commandfor="settings-quality-menu" type="quality" data-setting="quality" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('switches', { class: 'media-icon' })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -133,7 +135,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('speech', { class: 'media-icon' })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -141,7 +143,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('speed', { class: 'media-icon' })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -149,7 +151,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-captions-menu" type="captions" data-setting="captions" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('captions-off', { class: 'media-icon' })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -161,7 +163,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-quality-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-quality-radio-group class="media-menu__group">
|
||||
@@ -183,7 +185,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-audio-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-audio-track-radio-group class="media-menu__group">
|
||||
@@ -201,7 +203,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-speed-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-playback-rate-radio-group class="media-menu__group">
|
||||
@@ -219,7 +221,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-captions-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-captions-radio-group class="media-menu__group">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import {
|
||||
badge,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from '@videojs/skins/default/tailwind/video.tailwind';
|
||||
import { createTemplate } from '@videojs/utils/dom';
|
||||
import { cn } from '@videojs/utils/style';
|
||||
import { renderText } from '../../i18n/render-text';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinElement } from '../skin-element';
|
||||
|
||||
@@ -134,14 +136,14 @@ function getTemplateHTML() {
|
||||
|
||||
<button commandfor="settings-menu" aria-labelledby="settings-label" class="${cn(button.base, button.subtle, button.icon, menu.settingsTrigger, 'media-button--settings')}">
|
||||
${renderIcon('gear', { class: cn(icon, menu.settingsIcon) })}
|
||||
<media-text id="settings-label" class="media-sr-only">Settings</media-text>
|
||||
${renderText(settingsText, { id: 'settings-label', class: 'media-sr-only' })}
|
||||
</button>
|
||||
<media-menu id="settings-menu" side="top" align="center" class="${menu.settings}">
|
||||
<media-menu-view class="${menu.rootView}">
|
||||
<div class="${menu.group}">
|
||||
<media-menu-item commandfor="settings-quality-menu" type="quality" data-setting="quality" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('switches', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -149,7 +151,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('speech', { class: icon })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.chevron) })}
|
||||
@@ -157,7 +159,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('speed', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -165,7 +167,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-captions-menu" type="captions" data-setting="captions" class="${cn(menu.item, 'media-menu__item--submenu')}">
|
||||
${renderIcon('captions-off', { class: cn(icon, menu.icon) })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
<span class="${menu.hint}">
|
||||
<media-menu-item-value class="${menu.hintLabel}"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron) })}
|
||||
@@ -177,7 +179,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-quality-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-quality-radio-group class="${menu.group}">
|
||||
@@ -199,7 +201,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-audio-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-audio-track-radio-group class="${menu.group}">
|
||||
@@ -217,7 +219,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-speed-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-playback-rate-radio-group class="${menu.group}">
|
||||
@@ -235,7 +237,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-captions-menu" class="${menu.submenuPanel}">
|
||||
<media-menu-back class="${menu.back}">
|
||||
${renderIcon('chevron', { class: cn(icon, menu.icon, menu.chevron, iconFlipped) })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
</media-menu-back>
|
||||
<div class="${menu.separator}"></div>
|
||||
<media-captions-radio-group class="${menu.group}">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
|
||||
import { renderIcon } from '@videojs/icons/render';
|
||||
import { createShadowStyle, createTemplate } from '@videojs/utils/dom';
|
||||
import { renderText } from '../../i18n/render-text';
|
||||
import { safeDefine } from '../safe-define';
|
||||
import { SkinElement } from '../skin-element';
|
||||
import styles from './skin.css?inline';
|
||||
@@ -112,14 +114,14 @@ function getTemplateHTML() {
|
||||
|
||||
<button commandfor="settings-menu" aria-labelledby="settings-label" class="media-button media-button--subtle media-button--icon media-button--settings">
|
||||
${renderIcon('gear', { class: 'media-icon media-icon--settings' })}
|
||||
<media-text id="settings-label" class="media-sr-only">Settings</media-text>
|
||||
${renderText(settingsText, { id: 'settings-label', class: 'media-sr-only' })}
|
||||
</button>
|
||||
<media-menu id="settings-menu" side="top" align="center" class="media-surface media-popover media-menu media-menu--settings">
|
||||
<media-menu-view class="media-menu__panel">
|
||||
<div class="media-menu__group">
|
||||
<media-menu-item commandfor="settings-quality-menu" type="quality" data-setting="quality" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('switches', { class: 'media-icon' })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -127,7 +129,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-audio-menu" type="audio-track" data-setting="audio-track" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('speech', { class: 'media-icon' })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -135,7 +137,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-speed-menu" type="playback-rate" data-setting="playback-rate" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('speed', { class: 'media-icon' })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -143,7 +145,7 @@ function getTemplateHTML() {
|
||||
</media-menu-item>
|
||||
<media-menu-item commandfor="settings-captions-menu" type="captions" data-setting="captions" class="media-menu__item media-menu__item--submenu">
|
||||
${renderIcon('captions-off', { class: 'media-icon' })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
<span class="media-menu__hint">
|
||||
<media-menu-item-value class="media-menu__hint-label"></media-menu-item-value>
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron' })}
|
||||
@@ -155,7 +157,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-quality-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Quality</media-text>
|
||||
${renderText(qualityText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-quality-radio-group class="media-menu__group">
|
||||
@@ -177,7 +179,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-audio-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Audio</media-text>
|
||||
${renderText(audioText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-audio-track-radio-group class="media-menu__group">
|
||||
@@ -195,7 +197,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-speed-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Speed</media-text>
|
||||
${renderText(speedText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-playback-rate-radio-group class="media-menu__group">
|
||||
@@ -213,7 +215,7 @@ function getTemplateHTML() {
|
||||
<media-menu id="settings-captions-menu" class="media-menu__panel">
|
||||
<media-menu-back class="media-menu__back">
|
||||
${renderIcon('chevron', { class: 'media-icon media-menu__chevron media-icon--flipped' })}
|
||||
<media-text>Captions</media-text>
|
||||
${renderText(captionsText)}
|
||||
</media-menu-back>
|
||||
<div class="media-menu__separator"></div>
|
||||
<media-captions-radio-group class="media-menu__group">
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Text, TextParams } from '@videojs/core/i18n';
|
||||
|
||||
/** Serialize text content and parameters so dynamic labels invalidate their caches. */
|
||||
export function cacheKey(text: Text | string, params?: TextParams): string {
|
||||
return JSON.stringify([text, params]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import '../define/i18n';
|
||||
|
||||
export type {
|
||||
FlatTranslations,
|
||||
Locale,
|
||||
TranslationParams,
|
||||
Translations,
|
||||
@@ -12,6 +13,7 @@ export {
|
||||
findLocaleKeys,
|
||||
getI18nTranslations,
|
||||
hasRegisteredLocale,
|
||||
isText,
|
||||
onI18nRegistryChange,
|
||||
registerI18n,
|
||||
} from '@videojs/core/i18n';
|
||||
@@ -34,3 +36,4 @@ export {
|
||||
I18nProviderMixin,
|
||||
MediaI18nProviderElement,
|
||||
} from './provider-element';
|
||||
export { renderText } from './render-text';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
createTranslator,
|
||||
loadLocale as defaultLoader,
|
||||
type FlatTranslations,
|
||||
findLocaleKeys,
|
||||
getBrowserTranslations,
|
||||
getI18nTranslations,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
onI18nRegistryChange,
|
||||
registerI18n,
|
||||
shouldAttemptBrowserTranslation,
|
||||
type Translations,
|
||||
} from '@videojs/core/i18n';
|
||||
import type { PropertyValues, ReactiveElement } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
@@ -53,7 +53,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18
|
||||
#registryUnsubscribe: (() => void) | undefined;
|
||||
#ambientUnsubscribe: (() => void) | undefined;
|
||||
#registryEpoch = 0;
|
||||
#lazyLayer: Partial<Translations> = {};
|
||||
#lazyLayer: Partial<FlatTranslations> = {};
|
||||
#lazySeq = 0;
|
||||
/** Tracks locale used for `#lazyLayer`; ambient `lang` can change without the `lang` property. */
|
||||
#resolvedLocaleForLazy: Locale | undefined;
|
||||
@@ -65,7 +65,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18
|
||||
};
|
||||
#publishedLocale: Locale | undefined;
|
||||
#publishedRegistryEpoch = -1;
|
||||
#publishedLazyLayer: Partial<Translations> | undefined;
|
||||
#publishedLazyLayer: Partial<FlatTranslations> | undefined;
|
||||
|
||||
protected get i18nValue(): I18nContextValue {
|
||||
return this.#i18nValue;
|
||||
@@ -144,7 +144,7 @@ export function createI18nProviderMixin({ context, loader = defaultLoader }: I18
|
||||
return;
|
||||
}
|
||||
const registryLayer = getI18nTranslations(locale);
|
||||
const translations: Translations = {
|
||||
const translations: FlatTranslations = {
|
||||
...registryLayer,
|
||||
...this.#lazyLayer,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Text } from '@videojs/core/i18n';
|
||||
import { escapeHtml } from '@videojs/utils/string';
|
||||
|
||||
/** Render a text descriptor as keyed media-text markup. */
|
||||
export function renderText(text: Text, attrs?: Record<string, string>): string {
|
||||
const attrText = Object.entries(attrs ?? {})
|
||||
.map(([key, value]) => ` ${key}="${escapeHtml(value)}"`)
|
||||
.join('');
|
||||
|
||||
return `<media-text token="${escapeHtml(text.key)}"${attrText}>${escapeHtml(text.text)}</media-text>`;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('media-i18n uses explicit lang for registry copy', async () => {
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'fr');
|
||||
document.body.appendChild(provider);
|
||||
@@ -27,10 +27,25 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('media-text translates text content inside provider', async () => {
|
||||
registerI18n('de', { Play: 'Los' });
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'de');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(text.textContent).toBe('Los');
|
||||
});
|
||||
|
||||
it('media-text resolves a token with inline English fallback', async () => {
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'de');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -48,10 +63,11 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('media-text stores source text before translating', async () => {
|
||||
registerI18n('de', { Play: 'Los' });
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'de');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -86,10 +102,11 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('inherits ambient html lang when provider has no lang', async () => {
|
||||
registerI18n('es', { Play: 'Ir' });
|
||||
registerI18n('es', { 'buttons.play': 'Ir' });
|
||||
document.documentElement.lang = 'es';
|
||||
const provider = new MediaI18nProviderElement();
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -99,11 +116,12 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('updates media-text when html lang changes', async () => {
|
||||
registerI18n('x-test-de', { Play: 'Los' });
|
||||
registerI18n('x-test-fr', { Play: 'Lire' });
|
||||
registerI18n('x-test-de', { 'buttons.play': 'Los' });
|
||||
registerI18n('x-test-fr', { 'buttons.play': 'Lire' });
|
||||
document.documentElement.lang = 'x-test-de';
|
||||
const provider = new MediaI18nProviderElement();
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -119,8 +137,8 @@ describe('createI18n (HTML)', () => {
|
||||
it('reloads builtin lazy overlays when ambient html lang changes', async () => {
|
||||
const { ProviderMixin, TextMixin } = createI18n({
|
||||
loader: async (tag) => {
|
||||
if (tag === 'x-test-lazy-de') return { Play: 'BuiltinDe' };
|
||||
if (tag === 'x-test-lazy-fr') return { Play: 'BuiltinFr' };
|
||||
if (tag === 'x-test-lazy-de') return { 'buttons.play': 'BuiltinDe' };
|
||||
if (tag === 'x-test-lazy-fr') return { 'buttons.play': 'BuiltinFr' };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -132,6 +150,7 @@ describe('createI18n (HTML)', () => {
|
||||
document.documentElement.lang = 'x-test-lazy-de';
|
||||
const provider = new LazyAmbientProvider();
|
||||
const text = new LazyAmbientText();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -146,11 +165,12 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('updates media-text when provider lang changes', async () => {
|
||||
registerI18n('de', { Play: 'Los' });
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'de');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -166,8 +186,8 @@ describe('createI18n (HTML)', () => {
|
||||
it('discards stale builtin load when provider lang is set right after insert', async () => {
|
||||
const { ProviderMixin, TextMixin } = createI18n({
|
||||
loader: async (tag) => {
|
||||
if (tag === 'en') return { Play: 'BuiltinEn' };
|
||||
if (tag === 'de') return { Play: 'BuiltinDe' };
|
||||
if (tag === 'en') return { 'buttons.play': 'BuiltinEn' };
|
||||
if (tag === 'de') return { 'buttons.play': 'BuiltinDe' };
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -179,6 +199,7 @@ describe('createI18n (HTML)', () => {
|
||||
document.documentElement.lang = 'en';
|
||||
const provider = new DriftProvider();
|
||||
const text = new DriftText();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -189,7 +210,7 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('shares Lit i18n context between createI18n() factories', async () => {
|
||||
registerI18n('de', { Play: 'Los' });
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
const { ProviderMixin: AProvider, TextMixin: AText } = createI18n();
|
||||
const { TextMixin: BText } = createI18n();
|
||||
class SharedProvider extends AProvider(ReactiveElement) {}
|
||||
@@ -203,6 +224,8 @@ describe('createI18n (HTML)', () => {
|
||||
provider.setAttribute('lang', 'de');
|
||||
const textSame = new SharedTextA();
|
||||
const textOther = new SharedTextB();
|
||||
textSame.setAttribute('token', 'buttons.play');
|
||||
textOther.setAttribute('token', 'buttons.play');
|
||||
textSame.textContent = 'Play';
|
||||
textOther.textContent = 'Play';
|
||||
provider.appendChild(textSame);
|
||||
@@ -220,7 +243,7 @@ describe('createI18n (HTML)', () => {
|
||||
readonly #i18n = new Ctor(this, context);
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.textContent = this.#i18n.value('Play');
|
||||
this.textContent = this.#i18n.value('buttons.play', { default: 'Play' });
|
||||
}
|
||||
}
|
||||
customElements.define('i18n-probe-fallback', Probe);
|
||||
@@ -235,7 +258,7 @@ describe('createI18n (HTML)', () => {
|
||||
class Probe extends ReactiveElement {
|
||||
readonly #i18n = new Ctor(this, context);
|
||||
protected override updated(): void {
|
||||
this.textContent = this.#i18n.value('Play');
|
||||
this.textContent = this.#i18n.value('buttons.play', { default: 'Play' });
|
||||
}
|
||||
}
|
||||
customElements.define('i18n-probe-fallback-registry', Probe);
|
||||
@@ -244,7 +267,7 @@ describe('createI18n (HTML)', () => {
|
||||
await el.updateComplete;
|
||||
expect(el.textContent).toBe('Play');
|
||||
|
||||
registerI18n('en', { Play: 'RegistryPlay' });
|
||||
registerI18n('en', { 'buttons.play': 'RegistryPlay' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(el.textContent).toBe('RegistryPlay');
|
||||
@@ -265,22 +288,22 @@ describe('createI18n (HTML)', () => {
|
||||
const second = el.i18n.value;
|
||||
expect(second).toBe(first);
|
||||
|
||||
registerI18n('en', { Play: 'RegistryPlay' });
|
||||
registerI18n('en', { 'buttons.play': 'RegistryPlay' });
|
||||
await el.updateComplete;
|
||||
|
||||
const third = el.i18n.value;
|
||||
expect(third).not.toBe(first);
|
||||
expect(third('Play')).toBe('RegistryPlay');
|
||||
expect(third('buttons.play')).toBe('RegistryPlay');
|
||||
});
|
||||
|
||||
it('provider keeps translator stable across unrelated updates', async () => {
|
||||
registerI18n('x-stable', { Play: 'StablePlay' });
|
||||
registerI18n('x-stable', { 'buttons.play': 'StablePlay' });
|
||||
const {
|
||||
context,
|
||||
I18nController: Ctor,
|
||||
ProviderMixin,
|
||||
} = createI18n({
|
||||
loader: async (tag) => (tag === 'x-stable' ? { Pause: 'LazyPause' } : undefined),
|
||||
loader: async (tag) => (tag === 'x-stable' ? { 'buttons.pause': 'LazyPause' } : undefined),
|
||||
});
|
||||
class StableProvider extends ProviderMixin(ReactiveElement) {}
|
||||
class Probe extends ReactiveElement {
|
||||
@@ -295,7 +318,7 @@ describe('createI18n (HTML)', () => {
|
||||
document.body.appendChild(provider);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probe.i18n.value('Pause')).toBe('LazyPause');
|
||||
expect(probe.i18n.value('buttons.pause')).toBe('LazyPause');
|
||||
});
|
||||
|
||||
const first = probe.i18n.value;
|
||||
@@ -305,34 +328,33 @@ describe('createI18n (HTML)', () => {
|
||||
|
||||
expect(probe.i18n.value).toBe(first);
|
||||
|
||||
registerI18n('x-stable', { Replay: 'StableReplay' });
|
||||
registerI18n('x-stable', { 'buttons.replay': 'StableReplay' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(probe.i18n.value).not.toBe(first);
|
||||
expect(probe.i18n.value('Replay')).toBe('StableReplay');
|
||||
expect(probe.i18n.value('buttons.replay')).toBe('StableReplay');
|
||||
});
|
||||
});
|
||||
|
||||
it('media-text refreshes fallback English without a provider when the registry changes', async () => {
|
||||
it('keeps media-text fallback English without a provider when the registry changes', async () => {
|
||||
const text = new MediaTextElement();
|
||||
text.textContent = 'Play';
|
||||
document.body.appendChild(text);
|
||||
await text.updateComplete;
|
||||
expect(text.textContent).toBe('Play');
|
||||
|
||||
registerI18n('en', { Play: 'RegistryPlay' });
|
||||
registerI18n('en', { 'buttons.play': 'RegistryPlay' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(text.textContent).toBe('RegistryPlay');
|
||||
});
|
||||
await vi.waitFor(() => expect(text.textContent).toBe('Play'));
|
||||
});
|
||||
|
||||
it('registers browser translations when no locale pack exists', async () => {
|
||||
vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ Play: 'BrowserPlay' });
|
||||
vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({ 'buttons.play': 'BrowserPlay' });
|
||||
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'xx');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -343,12 +365,13 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('skips browser translation when locale is already registered', async () => {
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations');
|
||||
|
||||
const provider = new MediaI18nProviderElement();
|
||||
provider.setAttribute('lang', 'fr');
|
||||
const text = new MediaTextElement();
|
||||
text.setAttribute('token', 'buttons.play');
|
||||
text.textContent = 'Play';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -361,11 +384,11 @@ describe('createI18n (HTML)', () => {
|
||||
|
||||
it('registers browser translations when a shipped locale pack is missing keys', async () => {
|
||||
const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockResolvedValue({
|
||||
Settings: 'Paramètres',
|
||||
} satisfies Partial<coreI18n.Translations>);
|
||||
'menu.settings': 'Paramètres',
|
||||
} satisfies Partial<coreI18n.FlatTranslations>);
|
||||
|
||||
const { ProviderMixin, TextMixin } = createI18n({
|
||||
loader: async (tag) => (tag === 'fr' ? { Play: 'Lire' } : undefined),
|
||||
loader: async (tag) => (tag === 'fr' ? { 'buttons.play': 'Lire' } : undefined),
|
||||
});
|
||||
class PartialProvider extends ProviderMixin(ReactiveElement) {}
|
||||
class PartialText extends TextMixin(ReactiveElement) {}
|
||||
@@ -375,6 +398,7 @@ describe('createI18n (HTML)', () => {
|
||||
const provider = new PartialProvider();
|
||||
provider.setAttribute('lang', 'fr');
|
||||
const text = new PartialText();
|
||||
text.setAttribute('token', 'menu.settings');
|
||||
text.textContent = 'Settings';
|
||||
provider.appendChild(text);
|
||||
document.body.appendChild(provider);
|
||||
@@ -386,7 +410,7 @@ describe('createI18n (HTML)', () => {
|
||||
});
|
||||
|
||||
it('does not register browser translations after locale changes', async () => {
|
||||
let resolveBrowser: ((value: Partial<coreI18n.Translations>) => void) | undefined;
|
||||
let resolveBrowser: ((value: Partial<coreI18n.FlatTranslations>) => void) | undefined;
|
||||
const getBrowserTranslations = vi.spyOn(coreI18n, 'getBrowserTranslations').mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
@@ -406,10 +430,10 @@ describe('createI18n (HTML)', () => {
|
||||
provider.setAttribute('lang', 'fr');
|
||||
await Promise.resolve();
|
||||
|
||||
resolveBrowser?.({ Play: 'StaleBrowserPlay' });
|
||||
resolveBrowser?.({ 'buttons.play': 'StaleBrowserPlay' });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(registerI18nSpy).not.toHaveBeenCalledWith('xx', { Play: 'StaleBrowserPlay' });
|
||||
expect(registerI18nSpy).not.toHaveBeenCalledWith('xx', { 'buttons.play': 'StaleBrowserPlay' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Locale, registerI18n, resetI18nRegistry, type Translator } from '@videojs/core/i18n';
|
||||
import { type Locale, registerI18n, resetI18nRegistry, type Text, type Translator } from '@videojs/core/i18n';
|
||||
import { type PropertyValues, ReactiveElement } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -8,7 +8,7 @@ import { i18nContext, MediaI18nProviderElement, MediaTextElement } from '../inde
|
||||
|
||||
const skinTemplate = document.createElement('template');
|
||||
skinTemplate.innerHTML =
|
||||
'<button aria-labelledby="settings-label"><media-text id="settings-label">Settings</media-text></button>';
|
||||
'<button aria-labelledby="settings-label"><media-text id="settings-label" token="menu.settings">Settings</media-text></button>';
|
||||
|
||||
const missingKeyTemplate = document.createElement('template');
|
||||
missingKeyTemplate.innerHTML =
|
||||
@@ -49,7 +49,10 @@ class TestI18nProviderElement extends ReactiveElement {
|
||||
readonly provider = new ContextProvider(this, {
|
||||
context: i18nContext,
|
||||
initialValue: {
|
||||
translator: ((key: string) => (key === 'Settings' ? 'Ancestor settings' : key)) as Translator,
|
||||
translator: ((value: string | Text) => {
|
||||
const key = typeof value === 'string' ? value : value.key;
|
||||
return key === 'menu.settings' ? 'Ancestor settings' : typeof value === 'string' ? value : value.text;
|
||||
}) as Translator,
|
||||
locale: 'xx' as Locale,
|
||||
},
|
||||
});
|
||||
@@ -71,7 +74,7 @@ if (!customElements.get('test-skin-i18n-first-text')) {
|
||||
customElements.define('test-skin-i18n-first-text', TestFirstTextElement);
|
||||
}
|
||||
|
||||
firstUpdateTemplate.innerHTML = '<test-skin-i18n-first-text>Settings</test-skin-i18n-first-text>';
|
||||
firstUpdateTemplate.innerHTML = '<test-skin-i18n-first-text token="menu.settings">Settings</test-skin-i18n-first-text>';
|
||||
|
||||
if (!customElements.get('test-skin-i18n-first-update')) {
|
||||
customElements.define('test-skin-i18n-first-update', TestFirstUpdateElement);
|
||||
@@ -96,7 +99,7 @@ describe('provider', () => {
|
||||
});
|
||||
|
||||
it('uses an ancestor translator for shadow labels', async () => {
|
||||
registerI18n('xx', { Settings: 'Skin settings' });
|
||||
registerI18n('xx', { 'menu.settings': 'Skin settings' });
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = /*html*/ `
|
||||
<test-skin-i18n-provider>
|
||||
@@ -116,7 +119,7 @@ describe('provider', () => {
|
||||
});
|
||||
|
||||
it('uses English fallback for shadow labels without a provider', async () => {
|
||||
registerI18n('xx', { Settings: 'Skin settings' });
|
||||
registerI18n('xx', { 'menu.settings': 'Skin settings' });
|
||||
const skin = document.createElement('test-skin-i18n') as TestSkinElement;
|
||||
skin.lang = 'xx';
|
||||
document.body.append(skin);
|
||||
@@ -128,7 +131,7 @@ describe('provider', () => {
|
||||
expect(text.textContent).toBe('Settings');
|
||||
});
|
||||
|
||||
it('falls back to the key when a shadow label is missing', async () => {
|
||||
it('keeps the child text when a shadow label has no token', async () => {
|
||||
const skin = document.createElement('test-skin-i18n-missing-key') as TestMissingKeyElement;
|
||||
document.body.append(skin);
|
||||
|
||||
@@ -140,8 +143,8 @@ describe('provider', () => {
|
||||
});
|
||||
|
||||
it('updates shadow labels when provider lang changes', async () => {
|
||||
registerI18n('xx', { Settings: 'Skin settings' });
|
||||
registerI18n('yy', { Settings: 'Other settings' });
|
||||
registerI18n('xx', { 'menu.settings': 'Skin settings' });
|
||||
registerI18n('yy', { 'menu.settings': 'Other settings' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
const skin = document.createElement('test-skin-i18n') as TestSkinElement;
|
||||
provider.lang = 'xx';
|
||||
@@ -160,7 +163,7 @@ describe('provider', () => {
|
||||
});
|
||||
|
||||
it('publishes provider lang before child text updates', async () => {
|
||||
registerI18n('xx', { Settings: 'Skin settings' });
|
||||
registerI18n('xx', { 'menu.settings': 'Skin settings' });
|
||||
const provider = new MediaI18nProviderElement();
|
||||
const skin = document.createElement('test-skin-i18n-first-update') as TestFirstUpdateElement;
|
||||
provider.lang = 'xx';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { renderText } from '../render-text';
|
||||
|
||||
describe('renderText', () => {
|
||||
it('renders a keyed text element with its English fallback', () => {
|
||||
expect(renderText({ key: 'menu.quality', text: 'Quality' })).toBe(
|
||||
'<media-text token="menu.quality">Quality</media-text>'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders escaped attributes and text', () => {
|
||||
expect(renderText({ key: 'menu.quality', text: '<Quality>' }, { 'data-label': 'a&b' })).toBe(
|
||||
'<media-text token="menu.quality" data-label="a&b"><Quality></media-text>'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Translator } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyValues, ReactiveElement } from '@videojs/element';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
import type { I18nContext } from './context';
|
||||
@@ -11,18 +11,16 @@ export interface TextMixinConfig {
|
||||
context: I18nContext;
|
||||
}
|
||||
|
||||
/** Authored phrase text is untyped; the runtime translator accepts any key. */
|
||||
function translateText(translator: Translator, key: string): string {
|
||||
// Omit the required params
|
||||
const translateLoose = translator as (k: string) => string;
|
||||
return translateLoose(key);
|
||||
}
|
||||
|
||||
export function createTextMixin({ context }: TextMixinConfig): I18nTextMixin {
|
||||
return (Base) => {
|
||||
class MediaText extends Base {
|
||||
static override properties = {
|
||||
token: { type: String },
|
||||
};
|
||||
|
||||
readonly #i18n = new I18nController(this, context);
|
||||
#text: string | undefined;
|
||||
token = '';
|
||||
|
||||
override connectedCallback(): void {
|
||||
this.#text ??= this.textContent?.trim() ?? '';
|
||||
@@ -31,7 +29,13 @@ export function createTextMixin({ context }: TextMixinConfig): I18nTextMixin {
|
||||
|
||||
protected override updated(changed: PropertyValues): void {
|
||||
super.updated(changed);
|
||||
this.textContent = this.#text ? translateText(this.#i18n.value, this.#text) : '';
|
||||
if (!this.#text) {
|
||||
this.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const text: Text | string = this.token ? { key: this.token, text: this.#text } : this.#text;
|
||||
this.textContent = typeof text === 'string' ? text : translateText(text, this.#i18n.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Translations } from '@videojs/core/i18n';
|
||||
import type { FlatTranslations } from '@videojs/core/i18n';
|
||||
import type { ReactiveElement } from '@videojs/element';
|
||||
import type { Constructor } from '@videojs/utils/types';
|
||||
|
||||
@@ -11,4 +11,4 @@ export type ReactiveElementMixinBase = Constructor<ReactiveElement> & Pick<typeo
|
||||
/**
|
||||
* Function to load partial or full translations for a given locale.
|
||||
*/
|
||||
export type LocaleLoader = (tag: string) => Promise<Partial<Translations> | undefined>;
|
||||
export type LocaleLoader = (tag: string) => Promise<Partial<FlatTranslations> | undefined>;
|
||||
|
||||
@@ -18,6 +18,7 @@ export { createSelector, shallowEqual } from '@videojs/store';
|
||||
export type {
|
||||
CreateI18nOptions,
|
||||
CreateI18nResult,
|
||||
FlatTranslations,
|
||||
I18nContextValue,
|
||||
Locale,
|
||||
TranslationParams,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AudioTrackRadioGroupCore, AudioTrackRadioGroupDataAttrs } from '@videojs/core';
|
||||
import { applyStateDataAttrs, logMissingFeature, selectAudioTrack } from '@videojs/core/dom';
|
||||
import { resolveTranslation, type Translator } from '@videojs/core/i18n';
|
||||
import { type Text, type Translator, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
|
||||
import { cacheKey } from '../../i18n/cache-key';
|
||||
import { i18nContext } from '../../i18n/context';
|
||||
import { I18nController } from '../../i18n/controller';
|
||||
import { playerContext } from '../../player/context';
|
||||
@@ -21,7 +21,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement {
|
||||
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
|
||||
|
||||
disabled = false;
|
||||
label = '';
|
||||
label: Text | string = '';
|
||||
formatTrack = AudioTrackRadioGroupCore.defaultProps.formatTrack;
|
||||
|
||||
readonly #core = new AudioTrackRadioGroupCore();
|
||||
@@ -55,12 +55,12 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement {
|
||||
let state: AudioTrackRadioGroupCore.State | null = null;
|
||||
|
||||
if (media) {
|
||||
this.#core.setProps({ formatTrack: this.formatTrack, disabled: this.disabled });
|
||||
this.#core.setProps({ formatTrack: this.formatTrack, disabled: this.disabled, label: this.label });
|
||||
this.#core.setMedia(media);
|
||||
state = this.#core.getState();
|
||||
|
||||
this.value = state.value;
|
||||
this.applyAriaLabel(this.#i18n.value, this.label || 'Audio');
|
||||
this.applyAriaLabel(this.#i18n.value, this.#core.getLabel(state));
|
||||
if (state.disabled) {
|
||||
this.setAttribute('aria-disabled', 'true');
|
||||
} else {
|
||||
@@ -78,7 +78,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement {
|
||||
const template = this.getTemplate();
|
||||
const templateKey = template?.innerHTML ?? '';
|
||||
const translator = this.#i18n.value;
|
||||
const tracksKey = `${state.tracks.map((track) => `${track.value}:${track.label}`).join('|')}::${this.#i18n.locale}::${templateKey}`;
|
||||
const tracksKey = `${state.tracks.map((track) => `${track.value}:${cacheKey(track.label)}`).join('|')}::${this.#i18n.locale}::${templateKey}`;
|
||||
|
||||
if (tracksKey !== this.#tracksKey || translator !== this.#tracksTranslator) {
|
||||
this.#tracksKey = tracksKey;
|
||||
@@ -90,9 +90,7 @@ export class AudioTrackRadioGroupElement extends MenuRadioGroupElement {
|
||||
}
|
||||
|
||||
this.append(
|
||||
...state.tracks.map((track) =>
|
||||
this.#createItem(track.value, resolveTranslation(translator, track.label), template)
|
||||
)
|
||||
...state.tracks.map((track) => this.#createItem(track.value, translateText(track.label, translator), template))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -194,7 +194,7 @@ describe('AudioTrackRadioGroupElement', () => {
|
||||
|
||||
await waitForMenu(menu, options);
|
||||
|
||||
registerI18n('x-test-audio', { Audio: 'Sound' });
|
||||
registerI18n('x-test-audio', { 'menu.audio': 'Sound' });
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
|
||||
@@ -202,7 +202,7 @@ describe('AudioTrackRadioGroupElement', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders translated Default track labels', async () => {
|
||||
it('keeps authored track labels literal', async () => {
|
||||
const { menu, options } = setup({
|
||||
locale: 'x-test-audio',
|
||||
audioTrackList: [
|
||||
@@ -213,11 +213,9 @@ describe('AudioTrackRadioGroupElement', () => {
|
||||
|
||||
await waitForMenu(menu, options);
|
||||
|
||||
registerI18n('x-test-audio', { Default: 'Standard' });
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
|
||||
expect(items.map((item) => item.textContent)).toEqual(['Standard', 'English']);
|
||||
expect(items.map((item) => item.textContent)).toEqual(['Default', 'English']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CAPTIONS_OFF_VALUE, CaptionsRadioGroupCore, CaptionsRadioGroupDataAttrs } from '@videojs/core';
|
||||
import { applyStateDataAttrs, logMissingFeature, selectTextTrack } from '@videojs/core/dom';
|
||||
import { resolveTranslation, type Translator } from '@videojs/core/i18n';
|
||||
import { type Text, type Translator, translateText } from '@videojs/core/i18n';
|
||||
import { offText } from '@videojs/core/i18n/text/menu';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
|
||||
import { cacheKey } from '../../i18n/cache-key';
|
||||
import { i18nContext } from '../../i18n/context';
|
||||
import { I18nController } from '../../i18n/controller';
|
||||
import { playerContext } from '../../player/context';
|
||||
@@ -21,7 +22,7 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement {
|
||||
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
|
||||
|
||||
disabled = false;
|
||||
label = '';
|
||||
label: Text | string = '';
|
||||
|
||||
readonly #core = new CaptionsRadioGroupCore();
|
||||
readonly #i18n = new I18nController(this, i18nContext);
|
||||
@@ -72,7 +73,7 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement {
|
||||
const template = this.getTemplate();
|
||||
const templateKey = template?.innerHTML ?? '';
|
||||
const translator = this.#i18n.value;
|
||||
const tracksKey = `${state.tracks.map((track) => `${track.value}:${track.label}`).join('|')}::${this.#i18n.locale}::${templateKey}`;
|
||||
const tracksKey = `${state.tracks.map((track) => `${track.value}:${cacheKey(track.label)}`).join('|')}::${this.#i18n.locale}::${templateKey}`;
|
||||
|
||||
if (tracksKey !== this.#tracksKey || translator !== this.#tracksTranslator) {
|
||||
this.#tracksKey = tracksKey;
|
||||
@@ -83,11 +84,9 @@ export class CaptionsRadioGroupElement extends MenuRadioGroupElement {
|
||||
child.remove();
|
||||
}
|
||||
|
||||
this.append(this.#createItem(CAPTIONS_OFF_VALUE, resolveTranslation(translator, 'Off'), template));
|
||||
this.append(this.#createItem(CAPTIONS_OFF_VALUE, translateText(offText, translator), template));
|
||||
this.append(
|
||||
...state.tracks.map((track) =>
|
||||
this.#createItem(track.value, resolveTranslation(translator, track.label), template)
|
||||
)
|
||||
...state.tracks.map((track) => this.#createItem(track.value, translateText(track.label, translator), template))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -114,7 +114,7 @@ describe('CaptionsRadioGroupElement', () => {
|
||||
|
||||
await options.updateComplete;
|
||||
|
||||
registerI18n('x-test-captions', { Captions: 'Legendes', Off: 'Desactive' });
|
||||
registerI18n('x-test-captions', { 'menu.captions': 'Legendes', 'menu.off': 'Desactive' });
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
|
||||
|
||||
@@ -2,8 +2,9 @@ import {
|
||||
AlertDialogDataAttrs,
|
||||
type AlertDialogInput,
|
||||
ErrorDialogCore,
|
||||
getErrorDialogDismissLabel,
|
||||
getErrorDialogTitleLabel,
|
||||
getErrorDialogDismissText,
|
||||
getErrorDialogTitleText,
|
||||
getErrorDialogUnexpectedText,
|
||||
type MediaError,
|
||||
resolveErrorDialogDescription,
|
||||
} from '@videojs/core';
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
createTransition,
|
||||
selectError,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { SnapshotController } from '@videojs/store/html';
|
||||
@@ -45,7 +46,7 @@ export class ErrorDialogElement extends MediaElement {
|
||||
#dialog: AlertDialogApi | null = null;
|
||||
#snapshot: SnapshotController<AlertDialogInput> | null = null;
|
||||
#lastError: MediaError | null = null;
|
||||
#lastDescription: string | null = null;
|
||||
#lastDescription: ReturnType<typeof resolveErrorDialogDescription> | null = null;
|
||||
#seenCopyParts = new WeakSet<HTMLElement>();
|
||||
#authoredCopyParts = new WeakSet<HTMLElement>();
|
||||
|
||||
@@ -132,7 +133,7 @@ export class ErrorDialogElement extends MediaElement {
|
||||
const t = this.#i18n.value;
|
||||
const title = this.querySelector<HTMLElement>('media-alert-dialog-title');
|
||||
if (title && !this.#hasAuthoredCopy(title)) {
|
||||
title.textContent = resolveTranslation(t, getErrorDialogTitleLabel());
|
||||
title.textContent = translateText(getErrorDialogTitleText(), t);
|
||||
}
|
||||
|
||||
const desc = this.querySelector<HTMLElement>('media-alert-dialog-description');
|
||||
@@ -141,13 +142,13 @@ export class ErrorDialogElement extends MediaElement {
|
||||
if (description) {
|
||||
this.#lastDescription = description;
|
||||
}
|
||||
const copy = description ?? this.#lastDescription ?? 'An unexpected error occurred.';
|
||||
desc.textContent = resolveTranslation(t, copy);
|
||||
const copy = description ?? this.#lastDescription;
|
||||
desc.textContent = copy ? translateText(copy, t) : translateText(getErrorDialogUnexpectedText(), t);
|
||||
}
|
||||
|
||||
const close = this.querySelector<HTMLElement>('media-alert-dialog-close');
|
||||
if (close && !this.#hasAuthoredCopy(close)) {
|
||||
close.textContent = resolveTranslation(t, getErrorDialogDismissLabel());
|
||||
close.textContent = translateText(getErrorDialogDismissText(), t);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,9 @@ describe('ErrorDialogElement', () => {
|
||||
|
||||
it('shows translated dialog copy when es locale is registered', async () => {
|
||||
registerI18n('es', {
|
||||
'Something went wrong.': 'Algo salió mal.',
|
||||
OK: 'Aceptar',
|
||||
'An unexpected error occurred.': 'Ocurrió un error inesperado.',
|
||||
'errors.title': 'Algo salió mal.',
|
||||
'common.ok': 'Aceptar',
|
||||
'errors.unexpected': 'Ocurrió un error inesperado.',
|
||||
});
|
||||
ensureDefined(MediaI18nProviderElement.tagName, MediaI18nProviderElement);
|
||||
ensureDefined(AlertDialogTitleElement.tagName, AlertDialogTitleElement);
|
||||
@@ -98,9 +98,9 @@ describe('ErrorDialogElement', () => {
|
||||
|
||||
it('preserves authored title copy', async () => {
|
||||
registerI18n('es', {
|
||||
'Something went wrong.': 'Algo salió mal.',
|
||||
OK: 'Aceptar',
|
||||
'An unexpected error occurred.': 'Ocurrió un error inesperado.',
|
||||
'errors.title': 'Algo salió mal.',
|
||||
'common.ok': 'Aceptar',
|
||||
'errors.unexpected': 'Ocurrió un error inesperado.',
|
||||
});
|
||||
ensureDefined(MediaI18nProviderElement.tagName, MediaI18nProviderElement);
|
||||
ensureDefined(AlertDialogTitleElement.tagName, AlertDialogTitleElement);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
selectLive,
|
||||
selectTime,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { resolveText, type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import type { State } from '@videojs/store';
|
||||
|
||||
@@ -35,7 +35,7 @@ export class LiveButtonElement extends MediaElement {
|
||||
};
|
||||
|
||||
disabled = false;
|
||||
label = '';
|
||||
label: Text | string = '';
|
||||
|
||||
protected readonly core = new LiveButtonCore();
|
||||
|
||||
@@ -48,14 +48,16 @@ export class LiveButtonElement extends MediaElement {
|
||||
return this.core.state;
|
||||
}
|
||||
|
||||
#defaultContent = false;
|
||||
#disconnect: AbortController | null = null;
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (!this.textContent?.trim()) {
|
||||
this.textContent = LiveButtonCore.defaultText;
|
||||
this.#defaultContent ||= !this.textContent?.trim();
|
||||
if (this.#defaultContent) {
|
||||
this.textContent = translateText(LiveButtonCore.defaultText, this.#i18n.value);
|
||||
}
|
||||
|
||||
this.#disconnect = new AbortController();
|
||||
@@ -83,7 +85,7 @@ export class LiveButtonElement extends MediaElement {
|
||||
|
||||
/** Returns the button's current label derived from media state. */
|
||||
getLabel(): string | undefined {
|
||||
return this.core.state.current.label || undefined;
|
||||
return this.core.state.current.label ? resolveText(this.core.state.current.label) : undefined;
|
||||
}
|
||||
|
||||
/** Resolved label for tooltips and other display surfaces. */
|
||||
@@ -91,7 +93,7 @@ export class LiveButtonElement extends MediaElement {
|
||||
const media = this.#getMedia();
|
||||
if (!media) return undefined;
|
||||
const state = this.core.getState();
|
||||
return resolveTranslation(this.#i18n.value, this.core.getLabel(state));
|
||||
return translateText(this.core.getLabel(state), this.#i18n.value);
|
||||
}
|
||||
|
||||
protected override willUpdate(changed: PropertyValues): void {
|
||||
@@ -102,6 +104,10 @@ export class LiveButtonElement extends MediaElement {
|
||||
protected override update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
if (this.#defaultContent) {
|
||||
this.textContent = translateText(LiveButtonCore.defaultText, this.#i18n.value);
|
||||
}
|
||||
|
||||
const media = this.#getMedia();
|
||||
if (!media) return;
|
||||
|
||||
@@ -110,7 +116,7 @@ export class LiveButtonElement extends MediaElement {
|
||||
const attrs = this.core.getAttrs(state);
|
||||
applyElementProps(this, {
|
||||
...attrs,
|
||||
'aria-label': resolveTranslation(this.#i18n.value, attrs['aria-label']),
|
||||
'aria-label': translateText(attrs['aria-label'], this.#i18n.value),
|
||||
});
|
||||
applyStateDataAttrs(this, state, LiveButtonDataAttrs);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AnyPlayerStore } from '@videojs/core/dom';
|
||||
import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { createStore } from '@videojs/store';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MediaI18nProviderElement } from '../../../i18n';
|
||||
import { playerContext } from '../../../player/context';
|
||||
import { MediaElement } from '../../media-element';
|
||||
import { LiveButtonElement } from '../live-button-element';
|
||||
|
||||
function defineElement(tagName: string, Base: CustomElementConstructor): void {
|
||||
if (!customElements.get(tagName)) {
|
||||
customElements.define(tagName, Base);
|
||||
}
|
||||
}
|
||||
|
||||
function createLiveStore(): AnyPlayerStore {
|
||||
return createStore<unknown>()({
|
||||
name: 'liveButton',
|
||||
state: () => ({
|
||||
liveEdgeStart: 90,
|
||||
targetLiveWindow: 0,
|
||||
currentTime: 80,
|
||||
duration: 100,
|
||||
seeking: false,
|
||||
seek: vi.fn(),
|
||||
buffered: [],
|
||||
seekable: [{ start: 0, end: 100 }],
|
||||
}),
|
||||
}) as unknown as AnyPlayerStore;
|
||||
}
|
||||
|
||||
class TestPlayerProviderElement extends MediaElement {
|
||||
readonly #provider = new ContextProvider(this, { context: playerContext });
|
||||
|
||||
setStore(store: AnyPlayerStore): void {
|
||||
this.#provider.setValue(store);
|
||||
}
|
||||
}
|
||||
|
||||
defineElement(LiveButtonElement.tagName, LiveButtonElement);
|
||||
defineElement(MediaI18nProviderElement.tagName, MediaI18nProviderElement);
|
||||
defineElement('test-live-button-player', TestPlayerProviderElement);
|
||||
|
||||
function setup(locale: string, text?: string) {
|
||||
const i18n = new MediaI18nProviderElement();
|
||||
const player = document.createElement('test-live-button-player') as TestPlayerProviderElement;
|
||||
const button = document.createElement(LiveButtonElement.tagName) as LiveButtonElement;
|
||||
|
||||
i18n.setAttribute('lang', locale);
|
||||
player.setStore(createLiveStore());
|
||||
if (text) button.textContent = text;
|
||||
player.append(button);
|
||||
i18n.append(player);
|
||||
document.body.append(i18n);
|
||||
|
||||
return { i18n, button };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetI18nRegistry();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('LiveButtonElement', () => {
|
||||
it('translates the default badge and updates when locale changes', async () => {
|
||||
registerI18n('es', { 'live.badge': 'En vivo' });
|
||||
registerI18n('fr', { 'live.badge': 'Direct' });
|
||||
const { i18n, button } = setup('es');
|
||||
|
||||
await button.updateComplete;
|
||||
expect(button.textContent).toBe('En vivo');
|
||||
|
||||
i18n.setAttribute('lang', 'fr');
|
||||
await vi.waitFor(() => {
|
||||
expect(button.textContent).toBe('Direct');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves authored badge copy', async () => {
|
||||
registerI18n('es', { 'live.badge': 'En vivo' });
|
||||
const { button } = setup('es', 'On air');
|
||||
|
||||
await button.updateComplete;
|
||||
expect(button.textContent).toBe('On air');
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
logMissingFeature,
|
||||
type UIEvent,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { isText, resolveText, type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import type { State } from '@videojs/store';
|
||||
|
||||
@@ -43,7 +43,7 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
};
|
||||
|
||||
disabled = false;
|
||||
label = '';
|
||||
label: Text | string = '';
|
||||
|
||||
protected abstract readonly core: Core;
|
||||
protected abstract readonly stateAttrMap: StateAttrMap<InferComponentState<Core>>;
|
||||
@@ -108,7 +108,7 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
|
||||
/** Returns the button's current label derived from media state. */
|
||||
getLabel(): string | undefined {
|
||||
return this.core.state.current.label || undefined;
|
||||
return this.core.state.current.label ? resolveText(this.core.state.current.label) : undefined;
|
||||
}
|
||||
|
||||
getShortcut(): string | undefined {
|
||||
@@ -120,7 +120,7 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
const media = this.mediaState.value;
|
||||
if (!media) return undefined;
|
||||
const state = this.core.getState() as InferComponentState<Core>;
|
||||
return resolveTranslation(this.#i18n.value, this.core.getLabel(state), getLabelParams(this.core, state));
|
||||
return translateText(this.core.getLabel(state), this.#i18n.value, getLabelParams(this.core, state));
|
||||
}
|
||||
|
||||
protected override willUpdate(changed: PropertyValues): void {
|
||||
@@ -140,8 +140,8 @@ export abstract class MediaButtonElement<Core extends MediaButtonComponent> exte
|
||||
this.core.setMedia(media);
|
||||
const state = this.core.getState() as InferComponentState<Core>;
|
||||
const attrs = (this.core.getAttrs?.(state) ?? {}) as Record<string, unknown>;
|
||||
if (typeof attrs['aria-label'] === 'string') {
|
||||
attrs['aria-label'] = resolveTranslation(this.#i18n.value, attrs['aria-label'], getLabelParams(this.core, state));
|
||||
if (isText(attrs['aria-label'])) {
|
||||
attrs['aria-label'] = translateText(attrs['aria-label'], this.#i18n.value, getLabelParams(this.core, state));
|
||||
}
|
||||
applyElementProps(this, {
|
||||
...attrs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core';
|
||||
import { applyElementProps, applyStateDataAttrs, logMissingFeature } from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { isText, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyValues } from '@videojs/element';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
|
||||
@@ -36,8 +36,8 @@ export abstract class MediaUIElement<Core extends MediaUIComponent> extends Medi
|
||||
const state = this.core.getState();
|
||||
if (isFunction(this.core.getAttrs)) {
|
||||
const attrs = this.core.getAttrs(state) as Record<string, unknown>;
|
||||
if (typeof attrs['aria-label'] === 'string') {
|
||||
attrs['aria-label'] = resolveTranslation(this.#i18n.value, attrs['aria-label']);
|
||||
if (isText(attrs['aria-label'])) {
|
||||
attrs['aria-label'] = translateText(attrs['aria-label'], this.#i18n.value);
|
||||
}
|
||||
applyElementProps(this, attrs);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,17 @@ import {
|
||||
QUALITY_AUTO_VALUE,
|
||||
type QualityRadioGroupCore,
|
||||
} from '@videojs/core';
|
||||
import type { Text, TextParams } from '@videojs/core/i18n';
|
||||
import { autoText, offText } from '@videojs/core/i18n/text/menu';
|
||||
|
||||
import type { MenuItemSettingType } from './menu-item-type';
|
||||
|
||||
export interface MenuItemSettingState {
|
||||
label: string;
|
||||
labelParams?: Record<string, string | number> | undefined;
|
||||
label: Text | string;
|
||||
labelParams?: TextParams | undefined;
|
||||
availability: 'available' | 'unavailable';
|
||||
}
|
||||
|
||||
function getAutoLabelState(label: string): Pick<MenuItemSettingState, 'label' | 'labelParams'> {
|
||||
const match = /^Auto \((.+)\)$/.exec(label);
|
||||
if (!match) return { label };
|
||||
return { label: 'Auto ({label})', labelParams: { label: match[1]! } };
|
||||
}
|
||||
|
||||
export function getMenuItemSettingState(
|
||||
type: MenuItemSettingType,
|
||||
cores: {
|
||||
@@ -53,7 +49,8 @@ export function getMenuItemSettingState(
|
||||
|
||||
if (state.value === QUALITY_AUTO_VALUE) {
|
||||
return {
|
||||
...getAutoLabelState(state.autoLabel),
|
||||
label: state.autoLabel,
|
||||
labelParams: state.autoLabelParams,
|
||||
availability: state.availability,
|
||||
};
|
||||
}
|
||||
@@ -61,7 +58,7 @@ export function getMenuItemSettingState(
|
||||
const rendition = state.renditions.find((candidate) => candidate.value === state.value);
|
||||
|
||||
return {
|
||||
label: rendition?.label ?? 'Auto',
|
||||
label: rendition?.label ?? autoText,
|
||||
availability: state.availability,
|
||||
};
|
||||
}
|
||||
@@ -81,13 +78,13 @@ export function getMenuItemSettingState(
|
||||
const state = cores.captions.getState();
|
||||
|
||||
if (state.value === CAPTIONS_OFF_VALUE) {
|
||||
return { label: 'Off', availability: state.availability };
|
||||
return { label: offText, availability: state.availability };
|
||||
}
|
||||
|
||||
const track = state.tracks.find((candidate) => candidate.value === state.value);
|
||||
|
||||
return {
|
||||
label: track?.label ?? 'Off',
|
||||
label: track?.label ?? offText,
|
||||
availability: state.availability,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { applyElementProps } from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import { backText } from '@videojs/core/i18n/text/menu';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
|
||||
@@ -15,7 +16,7 @@ export class MenuBackElement extends MediaElement {
|
||||
label: { type: String },
|
||||
} satisfies PropertyDeclarationMap<'label'>;
|
||||
|
||||
label = 'Back';
|
||||
label: Text | string = backText;
|
||||
|
||||
readonly #i18n = new I18nController(this, i18nContext);
|
||||
readonly #ctx = new ContextConsumer(this, { context: menuContext, subscribe: true });
|
||||
@@ -59,7 +60,7 @@ export class MenuBackElement extends MediaElement {
|
||||
|
||||
applyElementProps(this, {
|
||||
role: 'button',
|
||||
'aria-label': resolveTranslation(this.#i18n.value, this.label),
|
||||
'aria-label': translateText(this.label, this.#i18n.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
selectQuality,
|
||||
selectTextTrack,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer, ContextProvider } from '@videojs/element/context';
|
||||
import { i18nContext } from '../../i18n/context';
|
||||
@@ -171,7 +171,7 @@ export class MenuItemElement extends MediaElement {
|
||||
this.#settingProvider.setValue({
|
||||
type: this.type,
|
||||
...setting,
|
||||
label: resolveTranslation(this.#i18n.value, setting.label, setting.labelParams),
|
||||
label: translateText(setting.label, this.#i18n.value, setting.labelParams),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveTranslation, type Translator } from '@videojs/core/i18n';
|
||||
import { type Text, type Translator, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyValues } from '@videojs/element';
|
||||
|
||||
import { RadioGroupElement } from '../radio-group/radio-group-element';
|
||||
@@ -48,13 +48,17 @@ export class MenuRadioGroupElement extends RadioGroupElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected applyAriaLabel(translator: Translator, label: string, params?: Record<string, string | number>): void {
|
||||
protected applyAriaLabel(
|
||||
translator: Translator,
|
||||
label: Text | string,
|
||||
params?: Record<string, string | number>
|
||||
): void {
|
||||
if (this.hasAttribute('aria-labelledby')) return;
|
||||
|
||||
const current = this.getAttribute('aria-label');
|
||||
if (current !== null && current !== this.#ariaLabel) return;
|
||||
|
||||
this.#ariaLabel = resolveTranslation(translator, label, params);
|
||||
this.#ariaLabel = translateText(label, translator, params);
|
||||
this.setAttribute('aria-label', this.#ariaLabel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { MediaControlsState } from '@videojs/core';
|
||||
import type { AnyPlayerStore } from '@videojs/core/dom';
|
||||
import type { Text } from '@videojs/core/i18n';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { createStore, flush } from '@videojs/store';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -226,6 +227,15 @@ describe('MenuElement', () => {
|
||||
expect(child.hasAttribute('data-align')).toBe(false);
|
||||
expectNoMenuStateAttrs(back);
|
||||
expectNoMenuStateAttrs(childItem);
|
||||
expect(back.getAttribute('aria-label')).toBe('Back');
|
||||
|
||||
back.label = { key: 'custom.back', text: 'Go back' } as const satisfies Text;
|
||||
await back.updateComplete;
|
||||
expect(back.getAttribute('aria-label')).toBe('Go back');
|
||||
|
||||
back.label = 'menu.back';
|
||||
await back.updateComplete;
|
||||
expect(back.getAttribute('aria-label')).toBe('menu.back');
|
||||
});
|
||||
|
||||
it('marks root and nested menu views with generic view attributes', async () => {
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('MenuItemValueElement', () => {
|
||||
});
|
||||
|
||||
it('renders the active quality label when quality is automatic', async () => {
|
||||
registerI18n('x-test-quality-hint', { 'Auto ({label})': 'Automatico ({label})' });
|
||||
registerI18n('x-test-quality-hint', { 'menu.autoWithLabel': 'Automatico ({label})' });
|
||||
const { value } = setup(
|
||||
createQualityStore({
|
||||
activeVideoRendition: { id: '1', height: 720, selected: false },
|
||||
@@ -274,7 +274,7 @@ describe('MenuItemValueElement', () => {
|
||||
});
|
||||
|
||||
it('translates the fallback audio track label', async () => {
|
||||
registerI18n('x-test-audio-hint', { Audio: 'Audio test' });
|
||||
registerI18n('x-test-audio-hint', { 'menu.audio': 'Audio test' });
|
||||
const { value } = setup(
|
||||
createAudioTrackStore({
|
||||
audioTrackList: [
|
||||
@@ -293,7 +293,7 @@ describe('MenuItemValueElement', () => {
|
||||
});
|
||||
|
||||
it('renders the active caption track label', async () => {
|
||||
registerI18n('x-test-captions-hint', { Captions: 'Legendes' });
|
||||
registerI18n('x-test-captions-hint', { 'menu.captions': 'Legendes' });
|
||||
const { value } = setup(
|
||||
createTextTrackStore({
|
||||
textTrackList: [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { QUALITY_AUTO_VALUE, QualityRadioGroupCore, QualityRadioGroupDataAttrs } from '@videojs/core';
|
||||
import { applyStateDataAttrs, logMissingFeature, selectQuality } from '@videojs/core/dom';
|
||||
import { resolveTranslation, type Translator } from '@videojs/core/i18n';
|
||||
import { type Text, type Translator, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
|
||||
import { cacheKey } from '../../i18n/cache-key';
|
||||
import { i18nContext } from '../../i18n/context';
|
||||
import { I18nController } from '../../i18n/controller';
|
||||
import { playerContext } from '../../player/context';
|
||||
@@ -11,14 +11,6 @@ import { MenuItemIndicatorElement } from '../menu/menu-item-indicator-element';
|
||||
import { MenuRadioGroupElement } from '../menu/menu-radio-group-element';
|
||||
import { MenuRadioItemElement } from '../menu/menu-radio-item-element';
|
||||
|
||||
function resolveAutoLabel(translator: Translator, label: string): string {
|
||||
const match = /^Auto \((.+)\)$/.exec(label);
|
||||
if (match) {
|
||||
return resolveTranslation(translator, 'Auto ({label})', { label: match[1]! });
|
||||
}
|
||||
return resolveTranslation(translator, label);
|
||||
}
|
||||
|
||||
export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
static override readonly tagName = 'media-quality-radio-group';
|
||||
|
||||
@@ -29,7 +21,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
} satisfies PropertyDeclarationMap<'value' | 'label' | 'disabled'>;
|
||||
|
||||
disabled = false;
|
||||
label = '';
|
||||
label: Text | string = '';
|
||||
formatRendition = QualityRadioGroupCore.defaultProps.formatRendition;
|
||||
|
||||
readonly #core = new QualityRadioGroupCore();
|
||||
@@ -63,12 +55,12 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
let state: QualityRadioGroupCore.State | null = null;
|
||||
|
||||
if (media) {
|
||||
this.#core.setProps({ formatRendition: this.formatRendition, disabled: this.disabled });
|
||||
this.#core.setProps({ formatRendition: this.formatRendition, disabled: this.disabled, label: this.label });
|
||||
this.#core.setMedia(media);
|
||||
state = this.#core.getState();
|
||||
|
||||
this.value = state.value;
|
||||
this.applyAriaLabel(this.#i18n.value, this.label || 'Quality');
|
||||
this.applyAriaLabel(this.#i18n.value, this.#core.getLabel(state));
|
||||
this.#syncContent(state);
|
||||
}
|
||||
|
||||
@@ -82,8 +74,11 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
const templateKey = template?.innerHTML ?? '';
|
||||
const translator = this.#i18n.value;
|
||||
const renditionsKey = `${state.renditions
|
||||
.map((rendition) => `${rendition.value}:${rendition.label}:${rendition.tier ?? ''}:${rendition.badge ?? ''}`)
|
||||
.join('|')}::${state.autoLabel}::${this.#i18n.locale}::${templateKey}`;
|
||||
.map(
|
||||
(rendition) =>
|
||||
`${rendition.value}:${cacheKey(rendition.label)}:${rendition.tier ?? ''}:${rendition.badge ?? ''}`
|
||||
)
|
||||
.join('|')}::${cacheKey(state.autoLabel, state.autoLabelParams)}::${this.#i18n.locale}::${templateKey}`;
|
||||
|
||||
if (renditionsKey !== this.#renditionsKey || translator !== this.#renditionsTranslator) {
|
||||
this.#renditionsKey = renditionsKey;
|
||||
@@ -97,7 +92,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
this.append(
|
||||
this.#createItem(
|
||||
QUALITY_AUTO_VALUE,
|
||||
resolveAutoLabel(translator, state.autoLabel),
|
||||
translateText(state.autoLabel, translator, state.autoLabelParams),
|
||||
undefined,
|
||||
undefined,
|
||||
template
|
||||
@@ -107,7 +102,7 @@ export class QualityRadioGroupElement extends MenuRadioGroupElement {
|
||||
...state.renditions.map((rendition) =>
|
||||
this.#createItem(
|
||||
rendition.value,
|
||||
resolveTranslation(translator, rendition.label),
|
||||
translateText(rendition.label, translator),
|
||||
rendition.tier,
|
||||
rendition.badge,
|
||||
template
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('QualityRadioGroupElement', () => {
|
||||
|
||||
await waitForMenu(menu, options);
|
||||
|
||||
registerI18n('x-test-quality', { Auto: 'Automatique' });
|
||||
registerI18n('x-test-quality', { 'menu.auto': 'Automatique' });
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const items = [...menu.querySelectorAll<MenuRadioItemElement>(MenuRadioItemElement.tagName)];
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
getSliderCSSVars,
|
||||
type SliderApi,
|
||||
} from '@videojs/core/dom';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { applyStyles, isRTL } from '@videojs/utils/dom';
|
||||
|
||||
import { i18nContext } from '../../i18n/context';
|
||||
import { I18nController } from '../../i18n/controller';
|
||||
import { MediaElement } from '../media-element';
|
||||
import { sliderContext } from './context';
|
||||
|
||||
@@ -28,7 +30,7 @@ export class SliderElement extends MediaElement {
|
||||
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
|
||||
} satisfies PropertyDeclarationMap<keyof SliderCore.Props>;
|
||||
|
||||
label = SliderCore.defaultProps.label;
|
||||
label: Text | string = '';
|
||||
value = SliderCore.defaultProps.value;
|
||||
min = SliderCore.defaultProps.min;
|
||||
max = SliderCore.defaultProps.max;
|
||||
@@ -39,6 +41,7 @@ export class SliderElement extends MediaElement {
|
||||
thumbAlignment = SliderCore.defaultProps.thumbAlignment;
|
||||
|
||||
readonly #core = new SliderCore();
|
||||
readonly #i18n = new I18nController(this, i18nContext);
|
||||
readonly #provider = new ContextProvider(this, { context: sliderContext });
|
||||
|
||||
#slider: SliderApi | null = null;
|
||||
@@ -118,7 +121,10 @@ export class SliderElement extends MediaElement {
|
||||
state,
|
||||
stateAttrMap: SliderDataAttrs,
|
||||
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
|
||||
thumbAttrs: this.#core.getAttrs(state),
|
||||
thumbAttrs: (() => {
|
||||
const attrs = this.#core.getAttrs(state);
|
||||
return { ...attrs, 'aria-label': translateText(attrs['aria-label'], this.#i18n.value) };
|
||||
})(),
|
||||
thumbProps: this.#slider.thumbProps,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,8 +104,8 @@ describe('MediaButtonElement', () => {
|
||||
});
|
||||
|
||||
it('applies translated aria-label and updates on locale change', async () => {
|
||||
registerI18n('es', { Play: 'Reproducir' });
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('es', { 'buttons.play': 'Reproducir' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
|
||||
ensureDefined(PlayButtonElement);
|
||||
ensureDefined(MediaI18nProviderElement);
|
||||
@@ -131,8 +131,8 @@ describe('MediaButtonElement', () => {
|
||||
});
|
||||
|
||||
it('updates aria-label when html lang changes and provider has no explicit lang', async () => {
|
||||
registerI18n('de', { Play: 'Los' });
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('de', { 'buttons.play': 'Los' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
document.documentElement.lang = 'de';
|
||||
|
||||
ensureDefined(PlayButtonElement);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
selectPlayback,
|
||||
selectTime,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { applyStyles, isRTL } from '@videojs/utils/dom';
|
||||
@@ -37,7 +37,7 @@ export class TimeSliderElement extends MediaElement {
|
||||
pauseOnDrag: { type: Boolean, attribute: 'pause-on-drag' },
|
||||
} satisfies PropertyDeclarationMap<Exclude<keyof TimeSliderCore.Props, 'value' | 'min' | 'max'>>;
|
||||
|
||||
label = TimeSliderCore.defaultProps.label;
|
||||
label: Text | string = '';
|
||||
changeThrottle = TimeSliderCore.defaultProps.changeThrottle;
|
||||
step = TimeSliderCore.defaultProps.step;
|
||||
largeStep = TimeSliderCore.defaultProps.largeStep;
|
||||
@@ -165,10 +165,10 @@ export class TimeSliderElement extends MediaElement {
|
||||
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
|
||||
thumbAttrs: {
|
||||
...thumbAttrs,
|
||||
'aria-label': resolveTranslation(this.#i18n.value, thumbAttrs['aria-label']),
|
||||
'aria-valuetext': resolveTranslation(
|
||||
this.#i18n.value,
|
||||
'aria-label': translateText(thumbAttrs['aria-label'], this.#i18n.value),
|
||||
'aria-valuetext': translateText(
|
||||
thumbAttrs['aria-valuetext'],
|
||||
this.#i18n.value,
|
||||
this.#core.getValueTextParams(state)
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { MediaTimeState } from '@videojs/core';
|
||||
import type { AnyPlayerStore } from '@videojs/core/dom';
|
||||
import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { createStore } from '@videojs/store';
|
||||
import { formatTimeAsPhrase } from '@videojs/utils/time';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MediaI18nProviderElement } from '../../../i18n';
|
||||
import { playerContext } from '../../../player/context';
|
||||
import { MediaElement } from '../../media-element';
|
||||
import { TimeElement } from '../time-element';
|
||||
@@ -79,13 +82,21 @@ class TestPlayerProviderElement extends MediaElement {
|
||||
}
|
||||
|
||||
defineElement('test-time-player', TestPlayerProviderElement);
|
||||
defineElement(MediaI18nProviderElement.tagName, MediaI18nProviderElement);
|
||||
|
||||
async function setup(props: Partial<TimeElement> = {}) {
|
||||
async function setup(props: Partial<TimeElement> = {}, locale?: string) {
|
||||
const provider = document.createElement('test-time-player') as TestPlayerProviderElement;
|
||||
const time = createElement(TimeElement);
|
||||
|
||||
Object.assign(time, props);
|
||||
document.body.append(provider);
|
||||
if (locale) {
|
||||
const i18n = new MediaI18nProviderElement();
|
||||
i18n.setAttribute('lang', locale);
|
||||
i18n.append(provider);
|
||||
document.body.append(i18n);
|
||||
} else {
|
||||
document.body.append(provider);
|
||||
}
|
||||
provider.append(time);
|
||||
await time.updateComplete;
|
||||
await waitForAssertion(() => expect(time.textContent).toBeTruthy());
|
||||
@@ -94,6 +105,7 @@ async function setup(props: Partial<TimeElement> = {}) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetI18nRegistry();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
@@ -127,6 +139,19 @@ describe('TimeElement', () => {
|
||||
expect(time.getAttribute('aria-label')).toBe('1 minute, 30 seconds. Show remaining time.');
|
||||
});
|
||||
|
||||
it('formats toggle labels with the active locale', async () => {
|
||||
registerI18n('fr', { 'time.showRemaining': '{duration}. Afficher restant.' });
|
||||
|
||||
const { time } = await setup({ toggle: true }, 'fr');
|
||||
|
||||
expect(time.getAttribute('aria-label')).toBe(`${formatTimeAsPhrase(90, { locale: 'fr' })}. Afficher restant.`);
|
||||
|
||||
time.type = 'duration';
|
||||
await time.updateComplete;
|
||||
|
||||
expect(time.getAttribute('aria-label')).toBe(`${formatTimeAsPhrase(300, { locale: 'fr' })}. Afficher restant.`);
|
||||
});
|
||||
|
||||
it('does not toggle before media state is available', async () => {
|
||||
const provider = document.createElement('test-time-player') as TestPlayerProviderElement;
|
||||
const time = createElement(TimeElement);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TimeCore, TimeDataAttrs, type TimeType } from '@videojs/core';
|
||||
import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import { remainingSuffixText } from '@videojs/core/i18n/text/time';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { isInteractiveActivation } from '@videojs/utils/dom';
|
||||
import { formatTimeAsPhrase } from '@videojs/utils/time';
|
||||
@@ -23,7 +24,7 @@ export class TimeElement extends MediaElement {
|
||||
|
||||
type: TimeType = TimeCore.defaultProps.type;
|
||||
negativeSign = TimeCore.defaultProps.negativeSign;
|
||||
label = TimeCore.defaultProps.label;
|
||||
label: Text | string = '';
|
||||
toggle = TimeCore.defaultProps.toggle;
|
||||
|
||||
readonly #core = new TimeCore();
|
||||
@@ -98,21 +99,20 @@ export class TimeElement extends MediaElement {
|
||||
const attrs = this.#core.getAttrs(state, this.type);
|
||||
applyElementProps(this, {
|
||||
...attrs,
|
||||
'aria-label': resolveTranslation(this.#i18n.value, attrs['aria-label'], this.#getLabelParams(state)),
|
||||
'aria-label': translateText(attrs['aria-label'], this.#i18n.value, this.#getLabelParams(state)),
|
||||
});
|
||||
applyStateDataAttrs(this, state, TimeDataAttrs);
|
||||
}
|
||||
|
||||
#getLabelParams(state: TimeCore.State): { duration: string } | undefined {
|
||||
const params = this.#core.getLabelParams(state);
|
||||
if (!params || state.type !== 'remaining') {
|
||||
return params;
|
||||
}
|
||||
if (!params) return undefined;
|
||||
|
||||
const duration = formatTimeAsPhrase(Math.abs(state.seconds), { locale: this.#i18n.locale });
|
||||
|
||||
return {
|
||||
duration: resolveTranslation(this.#i18n.value, '{duration} remaining', {
|
||||
duration: formatTimeAsPhrase(Math.abs(state.seconds), { locale: this.#i18n.locale }),
|
||||
}),
|
||||
duration:
|
||||
state.type === 'remaining' ? translateText(remainingSuffixText, this.#i18n.value, { duration }) : duration,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ButtonState } from '@videojs/core';
|
||||
import type { AnyPlayerStore, PlayerTarget } from '@videojs/core/dom';
|
||||
import { HOTKEY_SHORTCUT_CHANGE_EVENT, playbackFeature } from '@videojs/core/dom';
|
||||
import { registerI18n, resetI18nRegistry } from '@videojs/core/i18n';
|
||||
import { registerI18n, resetI18nRegistry, resolveText, type Text } from '@videojs/core/i18n';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { createState, createStore } from '@videojs/store';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -54,7 +54,7 @@ class TestTriggerElement extends HTMLElement {
|
||||
shortcut: string | undefined = 'K';
|
||||
|
||||
getLabel(): string | undefined {
|
||||
return this.$state.current.label;
|
||||
return this.$state.current.label ? resolveText(this.$state.current.label) : undefined;
|
||||
}
|
||||
|
||||
getShortcut(): string | undefined {
|
||||
@@ -196,7 +196,7 @@ describe('TooltipElement', () => {
|
||||
});
|
||||
|
||||
it('shows translated label from the trigger control', async () => {
|
||||
registerI18n('es', { Play: 'Reproducir' });
|
||||
registerI18n('es', { 'buttons.play': 'Reproducir' });
|
||||
|
||||
ensureDefined(TestPlayerProviderElement);
|
||||
ensureDefined(PlayButtonElement);
|
||||
@@ -225,8 +225,8 @@ describe('TooltipElement', () => {
|
||||
});
|
||||
|
||||
it('updates tooltip text when provider locale changes', async () => {
|
||||
registerI18n('es', { Play: 'Reproducir' });
|
||||
registerI18n('fr', { Play: 'Lire' });
|
||||
registerI18n('es', { 'buttons.play': 'Reproducir' });
|
||||
registerI18n('fr', { 'buttons.play': 'Lire' });
|
||||
|
||||
ensureDefined(TestPlayerProviderElement);
|
||||
ensureDefined(PlayButtonElement);
|
||||
@@ -260,16 +260,16 @@ describe('TooltipElement', () => {
|
||||
expect(TooltipLabelElement.findIn(tooltip)?.textContent).toBe('Lire');
|
||||
});
|
||||
|
||||
it('falls back to translating getLabel when getResolvedLabel is undefined', async () => {
|
||||
registerI18n('es', { Play: 'Reproducir' });
|
||||
it('falls back to translating Text from getLabel when getResolvedLabel is undefined', async () => {
|
||||
registerI18n('es', { 'buttons.play': 'Reproducir' });
|
||||
|
||||
class StubTrigger extends HTMLElement {
|
||||
static readonly tagName = 'stub-tooltip-trigger';
|
||||
|
||||
readonly $state = { subscribe: () => () => {} };
|
||||
|
||||
getLabel(): string {
|
||||
return 'Play';
|
||||
getLabel(): Text {
|
||||
return { key: 'buttons.play', text: 'Play' };
|
||||
}
|
||||
|
||||
getResolvedLabel(): undefined {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type TooltipChangeDetails,
|
||||
type TooltipOpenChangeReason,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextConsumer } from '@videojs/element/context';
|
||||
import type { State } from '@videojs/store';
|
||||
@@ -41,7 +41,7 @@ import { TooltipLabelElement } from './tooltip-label-element';
|
||||
import { TooltipShortcutElement } from './tooltip-shortcut-element';
|
||||
|
||||
type TriggerElement = HTMLElement & {
|
||||
getLabel(): string | undefined;
|
||||
getLabel(): Text | string | undefined;
|
||||
getResolvedLabel?(): string | undefined;
|
||||
getShortcut?: (() => string | undefined) | undefined;
|
||||
$state: State<ButtonState>;
|
||||
@@ -265,7 +265,7 @@ export class TooltipElement extends MediaElement {
|
||||
const label = triggerEl.getLabel();
|
||||
let resolved = isFunction(triggerEl.getResolvedLabel) ? triggerEl.getResolvedLabel() : undefined;
|
||||
if (resolved === undefined && label) {
|
||||
resolved = resolveTranslation(this.#i18n.value, label);
|
||||
resolved = translateText(label, this.#i18n.value);
|
||||
}
|
||||
const shortcut = triggerEl.getShortcut?.();
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type SliderApi,
|
||||
selectVolume,
|
||||
} from '@videojs/core/dom';
|
||||
import { resolveTranslation } from '@videojs/core/i18n';
|
||||
import { type Text, translateText } from '@videojs/core/i18n';
|
||||
import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element';
|
||||
import { ContextProvider } from '@videojs/element/context';
|
||||
import { applyStyles, isRTL } from '@videojs/utils/dom';
|
||||
@@ -34,7 +34,7 @@ export class VolumeSliderElement extends MediaElement {
|
||||
thumbAlignment: { type: String, attribute: 'thumb-alignment' },
|
||||
} satisfies PropertyDeclarationMap<Exclude<keyof VolumeSliderCore.Props, 'value' | 'min' | 'max'>>;
|
||||
|
||||
label = VolumeSliderCore.defaultProps.label;
|
||||
label: Text | string = '';
|
||||
step = VolumeSliderCore.defaultProps.step;
|
||||
largeStep = VolumeSliderCore.defaultProps.largeStep;
|
||||
wheelStep = VolumeSliderCore.defaultProps.wheelStep;
|
||||
@@ -143,10 +143,10 @@ export class VolumeSliderElement extends MediaElement {
|
||||
pointerValue: this.#core.valueFromPercent(state.pointerPercent),
|
||||
thumbAttrs: {
|
||||
...thumbAttrs,
|
||||
'aria-label': resolveTranslation(this.#i18n.value, thumbAttrs['aria-label']),
|
||||
'aria-valuetext': resolveTranslation(
|
||||
this.#i18n.value,
|
||||
'aria-label': translateText(thumbAttrs['aria-label'], this.#i18n.value),
|
||||
'aria-valuetext': translateText(
|
||||
thumbAttrs['aria-valuetext'],
|
||||
this.#i18n.value,
|
||||
this.#core.getValueTextParams(state)
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user