Skip to content

feat(packages): opaque translation keys in core ui with duration formatting#1555

Closed
sampotts wants to merge 16 commits into
feat/i18n-html-layerfrom
feat/i18n-core-components
Closed

feat(packages): opaque translation keys in core ui with duration formatting#1555
sampotts wants to merge 16 commits into
feat/i18n-html-layerfrom
feat/i18n-core-components

Conversation

@sampotts
Copy link
Copy Markdown
Collaborator

@sampotts sampotts commented May 18, 2026

Closes #1366

Summary

Core media controls now treat labels and aria-label values as translation tokens (TranslationKeyOrString) so the HTML i18n layer can resolve them consistently. Optional label props share resolveOptionalControlLabel, and time / time-slider expose formatOptions for formatDuration-based accessibility copy.

Changes

  • Typed label slots and shared resolution helper across button, slider, and time cores; HTML/React adapters updated.
  • Duration and volume phrasing helpers in @videojs/utils/time with tests; core expectations aligned on registry keys and parametric labels (seek, playback rate).
  • Renamed phraseFormat to formatOptions where it configures spoken-duration output (aria-valuetext / phrase text), distinct from clock display.

Testing

  • pnpm -F @videojs/core build && pnpm typecheck
  • pnpm -F @videojs/core test
  • pnpm -F @videojs/react test

Made with Cursor


Note

Medium Risk
Touches many core UI controls and their HTML/React adapters by changing label/aria-* to flow through translation keys and new param resolution, which can cause regressions in accessibility text or tooltip copy if any adapter misses the new resolver. Formatting changes now depend on Intl.DurationFormat/Intl.NumberFormat behavior and locale wiring.

Overview
Core UI controls now return opaque i18n keys (new TranslationKeyOrString) for label and aria-label instead of hardcoded English strings, and shared helpers (resolveOptionalControlLabel, resolveControlAttrs, resolveControlLabel, resolveTranslationPhrase) were introduced to consistently resolve optional custom labels and parametric phrases.

Buttons/menus that need template params (notably seek and playback-rate) now expose getLabelParams, and sliders/time components switch their aria-valuetext to translation tokens plus params, with locale-aware formatting added via new @videojs/utils/time helpers (formatDuration, formatVolumePercent) and new formatOptions plumbed through TimeCore/TimeSliderCore.

HTML and React adapters were updated to instantiate an i18n controller/hook and apply resolved attributes/tooltips, and English translations/types were adjusted (new remainingTimeSuffix, updated volumeSliderValueTextMuted, removed unused keys) with tests updated/added accordingly.

Reviewed by Cursor Bugbot for commit 9ba7cc3. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v10-sandbox Ready Ready Preview, Comment May 22, 2026 2:13am

Request Review

Comment thread packages/utils/src/time/format.ts Outdated
Comment thread packages/core/src/core/ui/volume-slider/tests/volume-slider-core.test.ts Outdated
Comment thread packages/core/src/core/ui/volume-slider/volume-slider-core.ts Outdated
Comment thread packages/core/src/core/ui/time-slider/time-slider-core.ts Outdated
Comment thread packages/core/src/core/ui/volume-slider/volume-slider-core.ts Outdated
Comment thread packages/html/src/ui/live-button/live-button-element.ts
Comment thread packages/html/src/ui/playback-rate-menu/tests/playback-rate-menu-element.test.ts Outdated
Comment thread packages/html/src/ui/time/time-element.ts
Comment thread packages/utils/src/time/format.ts
Comment thread packages/utils/src/time/format.ts
Comment thread packages/core/src/core/ui/resolve-optional-control-label.ts
sampotts and others added 16 commits May 22, 2026 12:12
…atting

Use TranslationKeyOrString for control labels, centralize optional label
resolution, and expose formatOptions for formatDuration-based copy across
time controls and sliders; extend utils time helpers used by core and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use Number.isFinite for duration in time slider aria-valuetext so
duration 0 still formats. Fix formatOptions JSDoc for api-docs schema.

Cache optional control labels so impure label callbacks run once per
state. Inline duration decomposition in formatDuration (remove redundant
toDurationRecord filtering).

Co-authored-by: Cursor <cursoragent@cursor.com>
Match formatVolumePercent(0.333) in the valuetext rounding test so the
expectation follows the value passed through getAttrs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Return volumeSliderValueText* and timeSliderValueText* keys with params
instead of mixing localized Intl output with hardcoded English connectors.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wire resolveControlAttrs through HTML and React adapters so aria-label
and aria-valuetext translation keys receive label and valuetext params.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wire LiveButton through resolveControlAttrs like other media buttons.
Update html/react tests to expect resolved English aria-label strings.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wire PlaybackRateMenuElement, TimeElement, and Time.Value through
resolveControlAttrs so translation keys resolve before reaching the DOM.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse NumberFormat and DurationFormat instances keyed by locale
and style to avoid reconstructing them on every slider update.

Co-authored-by: Cursor <cursoragent@cursor.com>
Fall back to formatTimeAsPhrase or a simple percent string when
DurationFormat/NumberFormat construction or format throws.

Co-authored-by: Cursor <cursoragent@cursor.com>
Re-resolve when label changes for the same state snapshot so cache
hits do not return a stale custom label.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ters

Pass active i18n locale into formatDuration and formatVolumePercent so
aria-valuetext announcements match translated control labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use setFormatLocale on VolumeSliderCore instead of a public prop so
typecheck and api-docs schema validation stay clean.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pass active i18n locale into TimeCore formatOptions so spoken duration
and aria-valuetext match translated control labels.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add remainingTimeSuffix registry key and wire formatOptions.translate
from i18n adapters so spoken remaining time does not mix languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
Use Intl-formatted values directly for non-range time and unmuted volume
aria-valuetext, and drop unused playbackRateMultiplier,
timeSliderValueTextCurrent, and volumeSliderValueText translation keys.

Co-authored-by: Cursor <cursoragent@cursor.com>
englishTranslations was renamed to translations when en locale moved to a
default export; the stale import broke typecheck and runtime test setup.

Co-authored-by: Cursor <cursoragent@cursor.com>
@sampotts sampotts force-pushed the feat/i18n-core-components branch from bae55cd to 9ba7cc3 Compare May 22, 2026 02:13
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bae55cd. Configure here.

const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat;

const percentFormatters = new Map<string, Intl.NumberFormat>();
const durationFormatters = new Map<string, InstanceType<NonNullable<typeof DurationFormat>>>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Module-level formatter caches leak memory unboundedly

Low Severity

percentFormatters and durationFormatters are module-level Map instances that grow unboundedly. Every unique locale string (or locale+style combination) adds an entry that is never evicted. In applications where locale values are dynamically constructed or vary widely, these maps become a memory leak. A bounded LRU cache or WeakRef-based approach would be safer for long-lived processes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bae55cd. Configure here.

}

getValueTextParams(state: VolumeSliderState): { percent: string } {
return { percent: formatVolumePercent(state.value / 100, this.#formatLocale) };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant getValueTextParams call for non-muted volume valuetext

Low Severity

When not muted, getValueText returns the already-formatted percent string (not a translation key), yet resolveControlAttrs still calls getValueTextParams and passes those params to resolveTranslationPhrase. The translator doesn't find the formatted string as a key and returns it verbatim, making the params call wasteful. A dedicated non-parametric path or returning the value text key consistently (like the muted case) would be cleaner and avoid the unnecessary formatVolumePercent double-call.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit bae55cd. Configure here.

@sampotts
Copy link
Copy Markdown
Collaborator Author

Consolidating i18n work into a single feature branch (feat/i18n) until the feature is complete. This stacked PR is closed; we will reopen as a fresh split review stack from feat/i18n when ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant