diff --git a/demo/app-root.test.ts b/demo/app-root.test.ts index 95e1b070..90c10899 100644 --- a/demo/app-root.test.ts +++ b/demo/app-root.test.ts @@ -8,6 +8,8 @@ import demoCss from './index.css?raw'; import type { AppRoot } from './app-root'; import { NARROW_VIEWPORT } from './app-root'; import './app-root'; +import { setLocale, writeLocaleToUrl } from './demo-localization'; +import type { IAPlaybackControls } from '@src/elements/ia-playback-controls/ia-playback-controls'; /** * Sets the hash without firing hashchange, so a fixture created afterwards @@ -415,6 +417,97 @@ describe('AppRoot', () => { }); }); + describe('locale switch', () => { + afterEach(async () => { + // The configured locale and the URL param both outlive the fixture, so + // a test that switches to Spanish would otherwise leak into whatever + // runs next in this file. + await setLocale('en'); + writeLocaleToUrl('en'); + }); + + function backButtonLabel(el: AppRoot): string | null | undefined { + const controls = el + .querySelector('ia-playback-controls-story') + ?.shadowRoot?.querySelector('ia-playback-controls'); + return controls?.shadowRoot + ?.getElementById('back-btn') + ?.getAttribute('aria-label'); + } + + test('the EN/ES control switches a loaded element to Spanish live', async () => { + setHash('#elem-ia-playback-controls'); + const el = await appRoot(); + + await waitUntil( + () => el.querySelector('ia-playback-controls-story'), + ' was never rendered', + ); + await waitUntil( + () => backButtonLabel(el) != null, + 'the playback controls never rendered their back button', + ); + expect(backButtonLabel(el)).to.equal('Skip back ten seconds'); + + const esButton = el.querySelector('#ia-locale-es') as HTMLButtonElement; + esButton.click(); + + await waitUntil( + () => backButtonLabel(el) === 'Retroceder diez segundos', + 'the playback controls never switched to Spanish', + ); + expect(esButton.getAttribute('aria-pressed')).to.equal('true'); + }); + + test('a ?lang=es deep link starts the demo in Spanish', async () => { + window.history.replaceState( + null, + '', + '?lang=es#elem-ia-playback-controls', + ); + const el = await appRoot(); + + await waitUntil( + () => backButtonLabel(el) === 'Retroceder diez segundos', + 'the deep link never put the playback controls in Spanish', + ); + }); + + test('a quick EN click after ES wins, even if the Spanish load resolves later', async () => { + setHash('#elem-ia-playback-controls'); + const el = await appRoot(); + await waitUntil( + () => backButtonLabel(el) != null, + 'the playback controls never rendered their back button', + ); + + // Neither await is started before the next fires, the way two quick + // clicks would land. Spanish has to come over the network while + // English is already loaded, so the ES call is still the one in + // flight when the EN call starts. + const setLocaleOnEl = ( + el as unknown as { _setLocale(locale: 'en' | 'es'): Promise } + )._setLocale.bind(el); + const first = setLocaleOnEl('es'); + const second = setLocaleOnEl('en'); + await Promise.all([first, second]); + + expect(backButtonLabel(el)).to.equal('Skip back ten seconds'); + const enButton = el.querySelector('#ia-locale-en') as HTMLButtonElement; + expect(enButton.getAttribute('aria-pressed')).to.equal('true'); + }); + + test('moves into the bottom bar on a narrow viewport', async () => { + stubNarrowViewport(true); + const el = await appRoot(); + + expect(el.querySelector('#ia-bar #ia-locale-switch')).to.exist; + expect(el.querySelector('#ia-picker #ia-locale-switch')).to.not.exist; + expect(el.querySelector('#ia-content-header #ia-locale-switch')).to.not + .exist; + }); + }); + describe('sidebar toggle', () => { test('starts with the sidebar up on a wide viewport', async () => { stubNarrowViewport(false); @@ -879,6 +972,9 @@ describe('AppRoot', () => { barButton(el, 'prev'), barButton(el, 'name'), barButton(el, 'next'), + ...Array.from( + el.querySelectorAll('#ia-bar .ia-locale-btn'), + ), el.querySelector('#ia-picker-close') as HTMLElement, el.querySelector('#ia-picker-search') as HTMLElement, ...Array.from( diff --git a/demo/app-root.ts b/demo/app-root.ts index 3bde81d7..ac9662dc 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -7,6 +7,12 @@ import { customElement } from '@src/util/custom-element'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { HASH_PREFIX, tagFromHash } from './element-hash'; +import { + getLocale, + localeFromUrl, + setLocale, + writeLocaleToUrl, +} from './demo-localization'; // Globbed without `eager`, so the keys give us the full element list at build // time while each module is only fetched when its story is displayed. @@ -87,6 +93,9 @@ export class AppRoot extends LitElement { /** Sidebar highlight in the all-elements view, driven by scroll position. */ @state() private _activeTag?: string; + /** The demo's own locale, so the EN/ES control can show which is active. */ + @state() private _locale = getLocale(); + /** Whether the viewport calls for the phone layout. */ @state() private _narrow = isNarrowViewport(); @@ -136,6 +145,14 @@ export class AppRoot extends LitElement { // the first element rather than on all of them. The hash is written back // so the URL names what's showing and can be passed on as it is. if (narrowQuery.matches && !window.location.hash) this._focusFirstEntry(); + // A `?lang=es` deep link gives a reviewer a one-click way to see elements + // in Spanish, so it wins over whatever locale the page started in. + const urlLocale = localeFromUrl(); + if (urlLocale !== this._locale) { + this._setLocale(urlLocale).catch((e) => + console.warn('Failed to apply the deep-linked demo locale:', e), + ); + } // updated() owns the scroll spy, and disconnecting tore it down. An // unchanged hash resolves to the same StoryEntry object, so the assignment // above requests no update on its own, which would leave the spy dead. @@ -148,6 +165,14 @@ export class AppRoot extends LitElement { this._abortController?.abort(); } + /** Switches the demo's language, reflecting it in the URL and the page. */ + private _setLocale = async (locale: 'en' | 'es') => { + await setLocale(locale); + this._locale = locale; + document.documentElement.lang = locale; + writeLocaleToUrl(locale); + }; + private _onHashChange = () => { const focused = entryFromHash(window.location.hash); if (focused === this._focused) return; @@ -377,6 +402,7 @@ export class AppRoot extends LitElement {
${this._narrow ? nothing : this._renderNavToggle()}

Internet Archive Elements

+ ${this._narrow ? nothing : this._renderLocaleSwitch()}
${this._focused ? this._renderFocused(this._focused) @@ -423,6 +449,37 @@ export class AppRoot extends LitElement { `; } + /** + * Demo-only EN/ES toggle so a reviewer can see elements in Spanish from a + * PR preview, without digging through dev tools. It's the one place in + * this repo that owns a locale switch; everything under src/ just renders + * whatever locale this picks. + */ + private _renderLocaleSwitch(): TemplateResult { + const localeButton = (locale: 'en' | 'es', label: string) => html` + + `; + + return html` +
+ ${localeButton('en', 'EN')} ${localeButton('es', 'ES')} +
+ `; + } + private _renderSidebar() { const showingAll = !this._focused; const link = (entry: StoryEntry) => @@ -447,8 +504,9 @@ export class AppRoot extends LitElement { /** * The phone layout's way around: step to the element either side, or tap - * the name in the middle for the full list. Along the bottom of the screen - * where a thumb reaches it, and there at any scroll position. + * the name in the middle for the full list, or switch the demo's language. + * Along the bottom of the screen where a thumb reaches it, and there at any + * scroll position. */ private _renderBar(): TemplateResult { const back = this._neighbour(-1); @@ -496,6 +554,7 @@ export class AppRoot extends LitElement { > › + ${this._renderLocaleSwitch()} `; } diff --git a/demo/demo-localization.ts b/demo/demo-localization.ts new file mode 100644 index 00000000..a5dad12d --- /dev/null +++ b/demo/demo-localization.ts @@ -0,0 +1,38 @@ +import { configureLocalization } from '@lit/localize'; + +/** The URL query param a deep link uses to request a starting locale. */ +export const LANG_PARAM = 'lang'; + +/** + * The demo app is the one place in this repo allowed to call + * configureLocalization: @lit/localize throws if it's configured twice, and + * every element's own msg() calls expect a single app-level owner. The + * package's source never calls it; only this demo does, so a reviewer can + * view elements in Spanish from a PR preview. + */ +export const { getLocale, setLocale } = configureLocalization({ + sourceLocale: 'en', + targetLocales: ['es'], + // The demo is this package's own test bed, so it loads the built Spanish + // module straight from source rather than through a published dependency. + loadLocale: () => import('@src/locales/es'), +}); + +/** Reads the locale a deep link's `?lang=` param asks for. */ +export function localeFromUrl(): 'en' | 'es' { + const requested = new URLSearchParams(window.location.search).get(LANG_PARAM); + return requested === 'es' ? 'es' : 'en'; +} + +/** + * Writes the given locale into the URL's `lang` param, replacing history + * rather than pushing so switching languages doesn't add back-button stops. + * The source locale is the default, so it's left as an absent param rather + * than written out as `lang=en`. + */ +export function writeLocaleToUrl(locale: 'en' | 'es'): void { + const url = new URL(window.location.href); + if (locale === 'en') url.searchParams.delete(LANG_PARAM); + else url.searchParams.set(LANG_PARAM, locale); + window.history.replaceState(null, '', url); +} diff --git a/demo/index.css b/demo/index.css index bb2c20b4..cba9547b 100644 --- a/demo/index.css +++ b/demo/index.css @@ -382,6 +382,58 @@ app-root { align-items: center; } +/* Pushed to the end of whichever flex row it's in: the content header next + to the h1, or the phone bar after the next arrow. */ +#ia-locale-switch { + display: flex; + flex-shrink: 0; + gap: 0.25rem; + margin-left: auto; +} + +.ia-locale-btn { + padding: 0.3rem 0.6rem; + border: 1px solid #bbb; + border-radius: 4px; + background: #fff; + color: #213547; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.1s, + color 0.1s, + border-color 0.1s; +} + +.ia-locale-btn:hover { + background: #f0f0f0; + border-color: #767676; +} + +.ia-locale-btn.ia-locale-active { + color: #fff; + background: #194880; + border-color: #194880; +} + +/* In the phone bar the pair is sized in px with 44px tap targets, like the + rest of the bar, whatever root font size a story sets. */ +#ia-bar #ia-locale-switch { + align-items: center; + margin-left: 0; + padding: 0 4px; + border-left: 1px solid #e3e3e3; + gap: 2px; +} + +#ia-bar .ia-locale-btn { + min-width: 44px; + min-height: 44px; + padding: 0; + font-size: 13px; +} + #ia-content h2 { font-size: 1.1rem; font-weight: 600; diff --git a/eslint.config.mjs b/eslint.config.mjs index 309b8a7b..ed724e12 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,24 @@ const compat = new FlatCompat({ allConfig: js.configs.all, }); +// Flat config has no equivalent to extending a rule's existing options, so +// both `no-restricted-imports` blocks below spread this in rather than +// repeating it. +const customElementRestrictedImports = [ + { + name: 'lit/decorators.js', + importNames: ['customElement'], + message: + "Import customElement from '@src/util/custom-element' instead, so a tag already claimed by another bundle of this package is skipped rather than throwing.", + }, + { + name: 'lit/decorators/custom-element.js', + importNames: ['customElement'], + message: + "Import customElement from '@src/util/custom-element' instead, so a tag already claimed by another bundle of this package is skipped rather than throwing.", + }, +]; + export default [ ...compat.extends('plugin:@typescript-eslint/recommended'), { @@ -43,20 +61,7 @@ export default [ 'no-restricted-imports': [ 'error', { - paths: [ - { - name: 'lit/decorators.js', - importNames: ['customElement'], - message: - "Import customElement from '@src/util/custom-element' instead, so a tag already claimed by another bundle of this package is skipped rather than throwing.", - }, - { - name: 'lit/decorators/custom-element.js', - importNames: ['customElement'], - message: - "Import customElement from '@src/util/custom-element' instead, so a tag already claimed by another bundle of this package is skipped rather than throwing.", - }, - ], + paths: [...customElementRestrictedImports], }, ], }, @@ -81,4 +86,42 @@ export default [ '@typescript-eslint/no-unused-expressions': 'off', }, }, + { + // Only the demo app calls configureLocalization (demo/demo-localization.ts). + // @lit/localize throws if it's configured twice, so this package's own + // source never does. Test files are exempt: they stand in for the app to + // exercise a published locale module on its own. + files: ['src/**/*.ts'], + ignores: ['src/**/*.test.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + ...customElementRestrictedImports, + { + name: '@lit/localize', + importNames: [ + 'configureLocalization', + 'configureTransformLocalization', + ], + message: + 'Only the demo app (demo/demo-localization.ts) calls configureLocalization. @lit/localize throws if configured twice, so this package never does.', + }, + ], + patterns: [ + { + // configureLocalization and configureTransformLocalization also + // live at these deep import paths, which the `paths` entry + // above (matched on the `@lit/localize` specifier alone) + // doesn't catch. + group: ['@lit/localize/init/*'], + message: + 'Only the demo app (demo/demo-localization.ts) calls configureLocalization. @lit/localize throws if configured twice, so this package never does.', + }, + ], + }, + ], + }, + }, ];