Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions demo/app-root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<IAPlaybackControls>('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'),
'<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<void> }
)._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);
Expand Down Expand Up @@ -879,6 +972,9 @@ describe('AppRoot', () => {
barButton(el, 'prev'),
barButton(el, 'name'),
barButton(el, 'next'),
...Array.from(
el.querySelectorAll<HTMLElement>('#ia-bar .ia-locale-btn'),
),
el.querySelector('#ia-picker-close') as HTMLElement,
el.querySelector('#ia-picker-search') as HTMLElement,
...Array.from(
Expand Down
63 changes: 61 additions & 2 deletions demo/app-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -377,6 +402,7 @@ export class AppRoot extends LitElement {
<div id="ia-content-header">
${this._narrow ? nothing : this._renderNavToggle()}
<h1>Internet Archive Elements</h1>
${this._narrow ? nothing : this._renderLocaleSwitch()}
</div>
${this._focused
? this._renderFocused(this._focused)
Expand Down Expand Up @@ -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`
<button
type="button"
id="ia-locale-${locale}"
class="ia-locale-btn ${this._locale === locale
? 'ia-locale-active'
: ''}"
aria-pressed="${this._locale === locale}"
@click=${() =>
this._setLocale(locale).catch((e) =>
console.warn('Failed to switch the demo locale:', e),
)}
>
${label}
</button>
`;

return html`
<div id="ia-locale-switch" role="group" aria-label="Demo language">
${localeButton('en', 'EN')} ${localeButton('es', 'ES')}
</div>
`;
}

private _renderSidebar() {
const showingAll = !this._focused;
const link = (entry: StoryEntry) =>
Expand All @@ -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);
Expand Down Expand Up @@ -496,6 +554,7 @@ export class AppRoot extends LitElement {
>
›
</button>
${this._renderLocaleSwitch()}
</nav>
`;
}
Expand Down
38 changes: 38 additions & 0 deletions demo/demo-localization.ts
Original file line number Diff line number Diff line change
@@ -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);
}
52 changes: 52 additions & 0 deletions demo/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading