feat(packages): i18n (#1708)

This commit is contained in:
Sam Potts
2026-07-10 12:58:47 +10:00
committed by GitHub
parent 5f48fcc6d6
commit 028dadb385
561 changed files with 13059 additions and 1320 deletions
+45
View File
@@ -0,0 +1,45 @@
'use client';
import type { HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent } from 'react';
import { forwardRef, useEffect, useRef } from 'react';
import { useComposedRefs } from '../utils/use-composed-refs';
import { useContainerAttach } from './context';
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export const Container = forwardRef<HTMLDivElement, ContainerProps>(function Container(
{ children, tabIndex = 0, ...props },
ref
) {
const setContainer = useContainerAttach();
const internalRef = useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, internalRef);
useEffect(() => {
setContainer?.(internalRef.current);
return () => setContainer?.(null);
}, [setContainer]);
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
props.onPointerUp?.(event);
const el = internalRef.current;
if (!el) return;
// If nothing inside has focus, grab it so keyboard events reach hotkey listeners.
if (!el.contains(document.activeElement) || document.activeElement === document.body) {
el.focus({ preventScroll: true });
}
};
return (
<div ref={composedRef} tabIndex={tabIndex} {...props} onPointerUp={handlePointerUp}>
{children}
</div>
);
});
export namespace Container {
export type Props = ContainerProps;
}
+2 -42
View File
@@ -4,10 +4,8 @@ import type { Media } from '@videojs/core';
import type { MediaContainer, PopupGroup } from '@videojs/core/dom';
import type { UnknownState, UnknownStore } from '@videojs/store';
import { useStore } from '@videojs/store/react';
import type { Dispatch, HTMLAttributes, ReactNode, PointerEvent as ReactPointerEvent, SetStateAction } from 'react';
import { createContext, forwardRef, useContext, useEffect, useRef } from 'react';
import { useComposedRefs } from '../utils/use-composed-refs';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { createContext, useContext } from 'react';
export interface PlayerContextValue {
store: UnknownStore;
@@ -120,41 +118,3 @@ export function useContainerAttach(): Dispatch<SetStateAction<HTMLElement | null
const ctx = useContext(PlayerContext);
return ctx?.setContainer;
}
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
export const Container = forwardRef<HTMLDivElement, ContainerProps>(function Container(
{ children, tabIndex = 0, ...props },
ref
) {
const setContainer = useContainerAttach();
const internalRef = useRef<HTMLDivElement>(null);
const composedRef = useComposedRefs(ref, internalRef);
useEffect(() => {
setContainer?.(internalRef.current);
return () => setContainer?.(null);
}, [setContainer]);
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
props.onPointerUp?.(event);
const el = internalRef.current;
if (!el) return;
// If nothing inside has focus, grab it so keyboard events reach hotkey listeners.
if (!el.contains(document.activeElement) || document.activeElement === document.body) {
el.focus({ preventScroll: true });
}
};
return (
<div ref={composedRef} tabIndex={tabIndex} {...props} onPointerUp={handlePointerUp}>
{children}
</div>
);
});
export namespace Container {
export type Props = ContainerProps;
}
+2 -1
View File
@@ -19,7 +19,8 @@ import type { FC, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useDestroy } from '../utils/use-destroy';
import { Container, PlayerContextProvider, useMedia, usePlayerContext } from './context';
import { Container } from './container';
import { PlayerContextProvider, useMedia, usePlayerContext } from './context';
export interface CreatePlayerConfig<Features extends AnyPlayerFeature[]> {
features: Features;
@@ -1,10 +1,11 @@
import { render, renderHook } from '@testing-library/react';
import { cleanup, render, renderHook, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createI18n } from '../../i18n/create-i18n';
import { createMockStore } from '../../testing/mocks';
import { Container } from '../container';
import {
Container,
PlayerContextProvider,
type PlayerContextValue,
useContainer,
@@ -17,6 +18,11 @@ import {
usePlayerContext,
} from '../context';
afterEach(() => {
cleanup();
document.documentElement.removeAttribute('lang');
});
function createWrapper(value: PlayerContextValue) {
return function Wrapper({ children }: { children: ReactNode }) {
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
@@ -269,4 +275,59 @@ describe('Container', () => {
expect(store.attach).not.toHaveBeenCalled();
});
it('does not create an i18n provider by default', async () => {
const value = createContextValue();
const loader = vi.fn(async (tag: string) => (tag === 'x-container' ? { Play: 'Container play' } : undefined));
const { useTranslator } = createI18n({ loader });
function Label() {
const t = useTranslator();
return <span>{t('Play')}</span>;
}
render(
<div lang="x-container">
<PlayerContextProvider value={value}>
<Container>
<Label />
</Container>
</PlayerContextProvider>
</div>
);
expect(screen.queryByText('Play')).not.toBeNull();
await Promise.resolve();
expect(loader).not.toHaveBeenCalled();
});
it('does not derive locale from container lang through an ancestor provider', async () => {
const value = createContextValue();
const loader = vi.fn(async (tag: string) => (tag === 'x-container' ? { Play: 'Container play' } : undefined));
const { I18nProvider, useTranslator } = createI18n({
loader,
});
function Label() {
const t = useTranslator();
return <span>{t('Play')}</span>;
}
render(
<I18nProvider>
<div lang="x-container">
<PlayerContextProvider value={value}>
<Container>
<Label />
</Container>
</PlayerContextProvider>
</div>
</I18nProvider>
);
expect(screen.queryByText('Play')).not.toBeNull();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(screen.queryByText('Container play')).toBeNull();
expect(loader).not.toHaveBeenCalledWith('x-container');
});
});
@@ -1,13 +1,18 @@
import { act, render, renderHook } from '@testing-library/react';
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import type { PlayerStore } from '@videojs/core/dom';
import { defineSlice } from '@videojs/store';
import type { ReactNode } from 'react';
import { StrictMode, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { usePlayerContext } from '../context';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider, useLocale } from '../../i18n';
import { useContainer, usePlayerContext } from '../context';
import { createPlayer } from '../create-player';
describe('createPlayer', () => {
afterEach(() => {
document.documentElement.removeAttribute('lang');
});
// Create a mock slice that works with any target
const mockSlice = defineSlice()({
state: () => ({
@@ -189,6 +194,53 @@ describe('createPlayer', () => {
expect(container.querySelector('[data-testid="child"]')).toBeTruthy();
});
it('does not derive a locale without an I18nProvider', async () => {
document.documentElement.lang = 'de';
const { Provider, Container } = createPlayer({ features: [mockSlice] });
function Locale() {
const container = useContainer();
const locale = useLocale();
return <span>{container ? locale : 'pending'}</span>;
}
render(
<Provider>
<Container>
<Locale />
</Container>
</Provider>
);
await waitFor(() => {
expect(screen.queryByText('en')).not.toBeNull();
});
});
it('inherits an explicit I18nProvider', async () => {
const { Provider, Container } = createPlayer({ features: [mockSlice] });
function Locale() {
const container = useContainer();
const locale = useLocale();
return <span>{container ? locale : 'pending'}</span>;
}
render(
<I18nProvider locale="de">
<Provider>
<Container>
<Locale />
</Container>
</Provider>
</I18nProvider>
);
await waitFor(() => {
expect(screen.queryByText('de')).not.toBeNull();
});
});
it('provides a stable context value across parent re-renders (fix for #1296)', () => {
const { Provider } = createPlayer({ features: [mockSlice] });