feat(i18n): convert to opaque keys (#1848)

This commit is contained in:
Sam Potts
2026-07-28 16:46:25 +10:00
committed by GitHub
parent be89470447
commit a3e673bd68
221 changed files with 7040 additions and 4307 deletions
+4 -1
View File
@@ -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}">
+11 -9
View File
@@ -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}">
+11 -9
View File
@@ -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">
+6
View File
@@ -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]);
}
+3
View File
@@ -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';
+4 -4
View File
@@ -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,
};
+11
View File
@@ -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' });
});
});
+13 -10
View File
@@ -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&amp;b">&lt;Quality&gt;</media-text>'
);
});
});
+13 -9
View File
@@ -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);
}
}
+2 -2
View File
@@ -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>;
+1
View File
@@ -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))
);
}
@@ -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))
);
}
@@ -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');
});
});
+6 -6
View File
@@ -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,
+3 -3
View File
@@ -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);
+9 -9
View File
@@ -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)
),
},