mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): i18n (#1708)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user