From 3792d15caf23f483d90c62676891814433ae0282 Mon Sep 17 00:00:00 2001 From: Isa HV Date: Mon, 20 Jul 2026 12:30:22 -0700 Subject: [PATCH 01/17] WEBDEV-8458 Migrate item-navigator into elements (labs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port @internetarchive/iaux-item-navigator into src/labs/ia-item-navigator, adopting the elements CSS convention, with a demo and tests. Scope: the navigator shell (ia-item-navigator + ia-menu-slider, ia-menu-button, ia-itemnav-loader, ia-no-theater-available) and the viewable-files menu (ia-viewable-files-panel + ia-sort-files-button). The bundled social share panel is left as a follow-up. - Rename to the ia-* convention (tag ia-item-navigator, class IAItemNavigator); sub-elements prefixed likewise. - Loosen deps: modal-manager and shared-resize-observer were type-only imports, now local interfaces (interfaces/service-interfaces.ts) — zero runtime dep. Keep @internetarchive/metadata-service (real runtime use in the item converter). Inline all icons (ellipses, collapse-sidebar, viewable-files, sort) instead of adding icon-package deps. - CSS convention: public theming vars become --item-navigator-* with --x-- private aliases and inline defaults; themeStyles imported per component; palette tokens replace hardcoded colors where they map. - Fix a latent toggle bug: the menu button bound @click=${this.toggleMenu}, passing the event as forceValue; now wrapped so menuOpened stays boolean. - ia-sort-files-button sorts a copy rather than mutating the reactive prop in place; sort icons use currentColor so they theme with the icon fill var. - Demo story (labs, auto-discovered) wires the real viewable-files panel as a menu provider with the sort button as its action; live style settings. - Vitest browser-mode tests (42) covering drawer, provider rendering, responsive mode, no-theater state, menu selection, file listing, PDF flagging, URL building and sort cycling. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 47 ++ package.json | 1 + .../ia-item-navigator-story.ts | 340 ++++++++++ .../ia-item-navigator.test.ts | 229 +++++++ .../ia-item-navigator/ia-item-navigator.ts | 591 ++++++++++++++++++ .../ia-itemnav-loader.test.ts | 36 ++ .../ia-item-navigator/ia-itemnav-loader.ts | 117 ++++ .../ia-item-navigator/ia-menu-button.test.ts | 71 +++ src/labs/ia-item-navigator/ia-menu-button.ts | 204 ++++++ .../ia-item-navigator/ia-menu-slider.test.ts | 108 ++++ src/labs/ia-item-navigator/ia-menu-slider.ts | 375 +++++++++++ .../ia-no-theater-available.test.ts | 45 ++ .../ia-no-theater-available.ts | 100 +++ src/labs/ia-item-navigator/icons.ts | 43 ++ .../interfaces/custom-theater-interface.ts | 34 + .../interfaces/event-interfaces.ts | 50 ++ .../interfaces/menu-interfaces.ts | 25 + .../interfaces/service-interfaces.ts | 38 ++ .../menus/ia-sort-files-button.test.ts | 103 +++ .../menus/ia-sort-files-button.ts | 190 ++++++ .../menus/ia-viewable-files-panel.test.ts | 102 +++ .../menus/ia-viewable-files-panel.ts | 219 +++++++ src/labs/ia-item-navigator/menus/models.ts | 17 + 23 files changed, 3085 insertions(+) create mode 100644 src/labs/ia-item-navigator/ia-item-navigator-story.ts create mode 100644 src/labs/ia-item-navigator/ia-item-navigator.test.ts create mode 100644 src/labs/ia-item-navigator/ia-item-navigator.ts create mode 100644 src/labs/ia-item-navigator/ia-itemnav-loader.test.ts create mode 100644 src/labs/ia-item-navigator/ia-itemnav-loader.ts create mode 100644 src/labs/ia-item-navigator/ia-menu-button.test.ts create mode 100644 src/labs/ia-item-navigator/ia-menu-button.ts create mode 100644 src/labs/ia-item-navigator/ia-menu-slider.test.ts create mode 100644 src/labs/ia-item-navigator/ia-menu-slider.ts create mode 100644 src/labs/ia-item-navigator/ia-no-theater-available.test.ts create mode 100644 src/labs/ia-item-navigator/ia-no-theater-available.ts create mode 100644 src/labs/ia-item-navigator/icons.ts create mode 100644 src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts create mode 100644 src/labs/ia-item-navigator/interfaces/event-interfaces.ts create mode 100644 src/labs/ia-item-navigator/interfaces/menu-interfaces.ts create mode 100644 src/labs/ia-item-navigator/interfaces/service-interfaces.ts create mode 100644 src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts create mode 100644 src/labs/ia-item-navigator/menus/ia-sort-files-button.ts create mode 100644 src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts create mode 100644 src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts create mode 100644 src/labs/ia-item-navigator/menus/models.ts diff --git a/package-lock.json b/package-lock.json index 734d64e..219a89e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@internetarchive/ia-clearable-text-input": "^1.1.1", "@internetarchive/ia-dropdown": "^2.2.0", + "@internetarchive/metadata-service": "^1.0.6", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", @@ -764,6 +765,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@internetarchive/field-parsers": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@internetarchive/field-parsers/-/field-parsers-0.1.4.tgz", + "integrity": "sha512-J5nhMsIU91eXnfoApa6SE0PffPbwK1Cd3YTLX/HjgayzMbXVkQvfebfVKSaoPmzHRRttbtY3Sg9FjviCwBq6qg==", + "license": "AGPL-3.0-only" + }, "node_modules/@internetarchive/ia-clearable-text-input": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@internetarchive/ia-clearable-text-input/-/ia-clearable-text-input-1.1.1.tgz", @@ -863,6 +870,22 @@ "@types/trusted-types": "^2.0.2" } }, + "node_modules/@internetarchive/iaux-item-metadata": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@internetarchive/iaux-item-metadata/-/iaux-item-metadata-1.2.0.tgz", + "integrity": "sha512-wcPMR0nyssKifW2x0/u9wuSM9M/BuvX8VntPK6THYn8HYk1A0CabWUtxGO7eGQWHebIdP1RH3XzHKUxrEV9c5Q==", + "license": "AGPL-3.0-only", + "dependencies": { + "@internetarchive/field-parsers": "^1.1.1", + "typescript-memoize": "^1.1.1" + } + }, + "node_modules/@internetarchive/iaux-item-metadata/node_modules/@internetarchive/field-parsers": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@internetarchive/field-parsers/-/field-parsers-1.1.1.tgz", + "integrity": "sha512-zYiXKsV31z2/QF9V6tLD5qun0Razr8HVs40tgFT2n6406Quf7V+7SbOjwhx7yMD1Y7qaeNJM5DDA/80CdEaP8w==", + "license": "AGPL-3.0-only" + }, "node_modules/@internetarchive/icon-close": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@internetarchive/icon-close/-/icon-close-1.4.1.tgz", @@ -872,6 +895,24 @@ "lit": "^2.0.2 || ^3.0.0" } }, + "node_modules/@internetarchive/metadata-service": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@internetarchive/metadata-service/-/metadata-service-1.0.6.tgz", + "integrity": "sha512-4jbBkFr3RZfslNXW+dmGdttB+meN+/ZGqNZ3PG0R/t173FwC/DvhI4qmkqQkyXxnNzt//807/M+Bj6N15WNx7g==", + "license": "AGPL-3.0-only", + "dependencies": { + "@internetarchive/field-parsers": "^0.1.4", + "@internetarchive/iaux-item-metadata": "^1.1.0", + "@internetarchive/result-type": "^0.0.1", + "typescript-memoize": "^1.1.1" + } + }, + "node_modules/@internetarchive/result-type": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@internetarchive/result-type/-/result-type-0.0.1.tgz", + "integrity": "sha512-sWahff5oP1xAK1CwAu1/5GTG2RXsdx/sQKn4SSOWH0r0vU2QoX9kAom/jSXeBsmgK0IjTc+9Ty9407SMORi+nQ==", + "license": "AGPL-3.0-only" + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -7542,6 +7583,12 @@ "node": ">=14.17" } }, + "node_modules/typescript-memoize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/typescript-memoize/-/typescript-memoize-1.1.1.tgz", + "integrity": "sha512-GQ90TcKpIH4XxYTI2F98yEQYZgjNMOGPpOgdjIBhaLaWji5HPWlRnZ4AeA1hfBxtY7bCGDJsqDDHk/KaHOl5bA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index ad2672b..c38a674 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@internetarchive/ia-clearable-text-input": "^1.1.1", "@internetarchive/ia-dropdown": "^2.2.0", + "@internetarchive/metadata-service": "^1.0.6", "@lit/localize": "^0.12.2", "lit": "^2.8.0 || ^3.3.2", "magic-snowflakes": "^7.0.2", diff --git a/src/labs/ia-item-navigator/ia-item-navigator-story.ts b/src/labs/ia-item-navigator/ia-item-navigator-story.ts new file mode 100644 index 0000000..ab035f2 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-item-navigator-story.ts @@ -0,0 +1,340 @@ +import { css, html, LitElement, type CSSResultGroup } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import './ia-item-navigator'; +import './menus/ia-viewable-files-panel'; +import './menus/ia-sort-files-button'; +import { viewableFilesIcon } from './menus/ia-viewable-files-panel'; +import type { MenuProviderInterface } from './interfaces/menu-interfaces'; +import type { FileSortOption, ViewableFileInfo } from './menus/models'; +import type { + SharedResizeObserverConfig, + SharedResizeObserverInterface, +} from './interfaces/service-interfaces'; +import type { StyleInputData } from '@demo/story-components/story-styles-settings'; +import '@demo/story-template'; + +/** + * A tiny adapter that satisfies the navigator's `SharedResizeObserverInterface` + * using a single native `ResizeObserver`, so the demo can show the responsive + * overlay/shift behavior without depending on + * `@internetarchive/shared-resize-observer`. + */ +class DemoResizeObserver implements SharedResizeObserverInterface { + private handlers = new Map(); + + private observer = new ResizeObserver((entries) => { + for (const entry of entries) { + this.handlers.get(entry.target)?.handler.handleResize(entry); + } + }); + + addObserver(config: SharedResizeObserverConfig): void { + this.handlers.set(config.target, config); + this.observer.observe(config.target); + } + + removeObserver(config: SharedResizeObserverConfig): void { + this.handlers.delete(config.target); + this.observer.unobserve(config.target); + } +} + +/** A generic list-ish glyph for demo menu entries. */ +const demoIcon = html` + +`; + +@customElement('ia-item-navigator-story') +export class IAItemNavigatorStory extends LitElement { + @state() private loaded = true; + + @state() private viewAvailable = true; + + @state() private sharedObserver = new DemoResizeObserver(); + + @state() private sortOrderBy: FileSortOption = 'default'; + + private readonly demoFiles: ViewableFileInfo[] = [ + { + title: 'Volume 1', + file_prefix: 'v1', + file_subprefix: 'v1', + url_path: '/details/demo-item/v1', + file_source: 'v1.pdf', + image: '', + author: '', + orig_sort: 0, + }, + { + title: 'Volume 3', + file_prefix: 'v3', + file_subprefix: 'v3', + url_path: '/details/demo-item/v3', + file_source: 'v3.txt', + image: '', + author: '', + orig_sort: 2, + }, + { + title: 'Volume 2', + file_prefix: 'v2', + file_subprefix: 'v2', + url_path: '/details/demo-item/v2', + file_source: 'v2.pdf', + image: '', + author: '', + orig_sort: 1, + }, + ]; + + @state() private sortedFiles: ViewableFileInfo[] = [...this.demoFiles]; + + private handleFileListSorted(e: Event): void { + const { sortType, sortedFiles } = (e as CustomEvent).detail; + this.sortOrderBy = sortType; + this.sortedFiles = sortedFiles; + } + + private get demoItem() { + // The navigator only reads `item?.metadata?.identifier`, so a plain object + // stands in for a full MetadataResponse in the demo. + return { + metadata: { identifier: 'demo-item', title: 'A Demonstration Item' }, + } as never; + } + + private get menuContents(): MenuProviderInterface[] { + const shared = { + item: this.demoItem, + baseHost: 'archive.org', + subPrefix: '', + }; + return [ + { + ...shared, + id: 'contents', + label: 'Table of Contents', + icon: demoIcon, + component: html` +
    +
  • Chapter 1 — Introduction
  • +
  • Chapter 2 — Getting Started
  • +
  • Chapter 3 — In Practice
  • +
+ `, + }, + { + ...shared, + id: 'viewable-files', + label: 'Viewable Files', + icon: viewableFilesIcon, + actionButton: html` + + `, + component: html` + + `, + }, + { + ...shared, + id: 'about', + label: 'About This Item', + icon: demoIcon, + component: html` +

+ A demonstration of the item navigator shell. Each menu entry above + is a "provider" supplying its own panel body. +

+ `, + }, + ]; + } + + private get styleInputData(): StyleInputData { + return { + settings: [ + { + label: 'Menu width', + cssVariable: '--item-navigator-menu-width', + defaultValue: 320, + inputType: 'range', + min: 200, + max: 480, + step: 10, + unit: 'px', + }, + { + label: 'Animation timing', + cssVariable: '--item-navigator-animation-timing', + defaultValue: 200, + inputType: 'range', + min: 0, + max: 800, + step: 50, + unit: 'ms', + }, + { + label: 'Theater background', + cssVariable: '--item-navigator-theater-bg-color', + defaultValue: '#000000', + inputType: 'color', + }, + { + label: 'Text color', + cssVariable: '--item-navigator-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Menu drawer background', + cssVariable: '--item-navigator-menu-slider-bg', + defaultValue: '#212121', + inputType: 'color', + }, + { + label: 'Active panel background', + cssVariable: '--item-navigator-active-button-bg', + defaultValue: '#333333', + inputType: 'color', + }, + ], + }; + } + + render() { + return html` + +
+
+ +
Demo item header
+
+

+ Your theater (book reader, media player, …) slots in here. +

+
+
+
+
+ +
+ + + + + + + + + +
Loaded + + (this.loaded = (e.target as HTMLInputElement).checked)} + /> +
View available + + (this.viewAvailable = ( + e.target as HTMLInputElement + ).checked)} + /> +
+

+ Toggle "View available" off to show the no-theater placeholder. + Narrow the demo below 600px wide to see the drawer switch from shift + to overlay mode. +

+
+ +
+

+ The navigator is a shell: project a theater into + slot="main" and drive the drawer with the + menuContents provider array. It never renders a viewer + itself. +

+
+
+ `; + } + + private get exampleUsage(): string { + return ` +
…your header…
+
…your theater…
+
`; + } + + static get styles(): CSSResultGroup { + return css` + .frame-wrapper { + height: 420px; + border: 1px solid #ccc; + } + + .demo-header { + background: #1a1a1a; + color: #fff; + padding: 8px 12px; + font-size: 0.9rem; + } + + .demo-theater { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #fff; + text-align: center; + padding: 1rem; + } + + .hint { + font-size: 0.78rem; + color: #555; + } + + table { + margin-bottom: 0.5rem; + } + `; + } +} diff --git a/src/labs/ia-item-navigator/ia-item-navigator.test.ts b/src/labs/ia-item-navigator/ia-item-navigator.test.ts new file mode 100644 index 0000000..69f0452 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-item-navigator.test.ts @@ -0,0 +1,229 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test, vi } from 'vitest'; + +import { IAItemNavigator } from './ia-item-navigator'; +import './ia-item-navigator'; +import type { IAMenuSlider } from './ia-menu-slider'; +import type { IANoTheaterAvailable } from './ia-no-theater-available'; +import type { MenuProviderInterface } from './interfaces/menu-interfaces'; + +/** Builds a minimal, well-typed menu provider for tests. */ +function provider( + id: string, + overrides: Partial = {}, +): MenuProviderInterface { + return { + id, + label: `${id} label`, + icon: html`${id}`, + item: { metadata: { identifier: 'test-item' } } as never, + baseHost: 'archive.org', + subPrefix: '', + component: html`
${id} body
`, + ...overrides, + }; +} + +describe('IAItemNavigator', () => { + test('renders the frame with sensible defaults', async () => { + const el = await fixture( + html``, + ); + + expect(el).to.be.instanceOf(IAItemNavigator); + expect(el.shadowRoot?.querySelector('#frame')).to.exist; + expect(el.viewAvailable).to.equal(true); + expect(el.loaded).to.equal(false); + expect(el.menuOpened).to.equal(false); + expect(el.openMenuState).to.equal('shift'); + expect(el.baseHost).to.equal('archive.org'); + }); + + test('shows the loading spinner until loaded, then hides it', async () => { + const el = await fixture( + html``, + ); + + expect(el.shadowRoot?.querySelector('ia-itemnav-loader')).to.exist; + + el.loaded = true; + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('ia-itemnav-loader')).to.not.exist; + }); + + test('renders no side menu when there are no providers', async () => { + const el = await fixture( + html``, + ); + + expect(el.shouldRenderMenu).to.equal(false); + expect(el.shadowRoot?.querySelector('nav')).to.not.exist; + }); + + test('renders the drawer and toggle button once providers are set', async () => { + const el = await fixture( + html``, + ); + el.menuContents = [provider('contents'), provider('about')]; + await el.updateComplete; + + expect(el.shouldRenderMenu).to.equal(true); + expect(el.shadowRoot?.querySelector('nav')).to.exist; + expect(el.shadowRoot?.querySelector('button.toggle-menu')).to.exist; + expect(el.shadowRoot?.querySelector('ia-menu-slider')).to.exist; + }); + + test('toggleMenu / closeMenu drive the menuOpened state', async () => { + const el = await fixture( + html``, + ); + el.menuContents = [provider('contents')]; + await el.updateComplete; + + el.toggleMenu(); + expect(el.menuOpened).to.equal(true); + + el.toggleMenu(); + expect(el.menuOpened).to.equal(false); + + el.toggleMenu(true); + expect(el.menuOpened).to.equal(true); + + el.closeMenu(); + expect(el.menuOpened).to.equal(false); + }); + + test('clicking the toggle button opens the drawer and marks the frame open', async () => { + const el = await fixture( + html``, + ); + el.menuContents = [provider('contents')]; + await el.updateComplete; + + const toggle = el.shadowRoot?.querySelector( + 'button.toggle-menu', + ) as HTMLButtonElement; + toggle.click(); + await el.updateComplete; + + expect(el.menuOpened).to.equal(true); + expect(el.shadowRoot?.querySelector('#frame')?.className).to.contain( + 'open', + ); + }); + + test('passes providers and selected menu through to the slider', async () => { + const el = await fixture( + html``, + ); + const providers = [provider('contents'), provider('about')]; + el.menuContents = providers; + el.openMenu = 'about'; + await el.updateComplete; + + const slider = el.shadowRoot?.querySelector('ia-menu-slider'); + expect(slider).to.exist; + expect(slider?.menus).to.have.lengthOf(2); + expect(slider?.selectedMenu).to.equal('about'); + }); + + test('shows the no-theater placeholder when the view is unavailable', async () => { + const el = await fixture( + html``, + ); + el.viewAvailable = false; + await el.updateComplete; + + const placeholder = el.shadowRoot?.querySelector( + 'ia-no-theater-available', + ); + expect(placeholder).to.exist; + expect(placeholder?.identifier).to.equal('abc123'); + }); + + test('loadingStateUpdated marks the navigator loaded', async () => { + const el = await fixture( + html``, + ); + + el.loadingStateUpdated( + new CustomEvent('loadingStateUpdated', { + detail: { loaded: true }, + }) as never, + ); + expect(el.loaded).to.equal(true); + }); + + test('slotChange re-dispatches a slotChange event with the slot type', async () => { + const el = await fixture( + html``, + ); + const listener = vi.fn(); + el.addEventListener('slotChange', listener); + + const slot = el.shadowRoot?.querySelector( + 'slot[name="header"]', + ) as HTMLSlotElement; + slot.dispatchEvent(new Event('slotchange')); + + expect(listener).toHaveBeenCalledOnce(); + expect(listener.mock.calls[0][0].detail.type).to.equal('header'); + }); + + test('manageViewportFullscreen reflects state and re-emits fullscreenToggled', async () => { + const el = await fixture( + html``, + ); + const listener = vi.fn(); + el.addEventListener('fullscreenToggled', listener); + + el.manageViewportFullscreen( + new CustomEvent('x', { detail: { isFullScreen: true } }) as never, + ); + expect(el.viewportInFullscreen).to.equal(true); + expect(listener).toHaveBeenCalledOnce(); + + el.manageViewportFullscreen( + new CustomEvent('x', { detail: { isFullScreen: false } }) as never, + ); + expect(el.viewportInFullscreen).to.equal(null); + }); + + test('handleResize switches between overlay and shift at 600px', async () => { + const el = await fixture( + html``, + ); + + el.handleResize({ contentRect: { width: 500 } } as ResizeObserverEntry); + expect(el.openMenuState).to.equal('overlay'); + + el.handleResize({ contentRect: { width: 900 } } as ResizeObserverEntry); + expect(el.openMenuState).to.equal('shift'); + }); + + test('setOpenMenu toggles the selected panel on repeat selection', async () => { + const el = await fixture( + html``, + ); + + el.setOpenMenu(new CustomEvent('x', { detail: { id: 'share' } }) as never); + expect(el.openMenu).to.equal('share'); + + el.setOpenMenu(new CustomEvent('x', { detail: { id: 'share' } }) as never); + expect(el.openMenu).to.equal(undefined); + }); + + test('openShortcut opens the drawer at the requested menu', async () => { + const el = await fixture( + html``, + ); + + el.openShortcut('contents'); + expect(el.openMenu).to.equal('contents'); + expect(el.menuOpened).to.equal(true); + }); +}); diff --git a/src/labs/ia-item-navigator/ia-item-navigator.ts b/src/labs/ia-item-navigator/ia-item-navigator.ts new file mode 100644 index 0000000..f0c799a --- /dev/null +++ b/src/labs/ia-item-navigator/ia-item-navigator.ts @@ -0,0 +1,591 @@ +import { + css, + html, + LitElement, + PropertyValues, + TemplateResult, + nothing, + type CSSResultGroup, +} from 'lit'; +import { customElement, property, state, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { MetadataResponse } from '@internetarchive/metadata-service'; +import themeStyles from '@src/themes/theme-styles'; + +import { ellipsesIcon } from './icons'; +import './ia-menu-slider'; +import './ia-itemnav-loader'; +import './ia-no-theater-available'; +import type { IAMenuSlider } from './ia-menu-slider'; + +import { + ToggleSideMenuOpenEvent, + ToggleSidePanelOpenEvent, + SetSideMenuContentsEvent, + SetSideMenuShortcutsEvent, + LoadingStateUpdatedEvent, + ManageFullscreenEvent, +} from './interfaces/event-interfaces'; +import { + MenuProviderInterface, + MenuShortcutInterface, + MenuId, +} from './interfaces/menu-interfaces'; +import { + ModalManagerInterface, + SharedResizeObserverInterface, + SharedResizeObserverResizeHandlerInterface, +} from './interfaces/service-interfaces'; + +/** + * A fullscreen-capable frame that hosts an Archive.org item's theater. The + * consumer projects a header bar and the theater itself into the `header` and + * `main` slots; a collapsible left drawer (`ia-menu-slider`) is driven by a + * data array of menu providers, and a minimized rail shows shortcut icons. + * + * The navigator is a shell — it does not know how to render any particular + * viewer. Menu panel bodies are opaque `TemplateResult`s supplied by the + * consumer via `menuContents`. + */ +@customElement('ia-item-navigator') +export class IAItemNavigator + extends LitElement + implements SharedResizeObserverResizeHandlerInterface +{ + @property({ + type: Object, + converter: (value: string | MetadataResponse | null): MetadataResponse => { + if (value && typeof value === 'string') { + return new MetadataResponse(JSON.parse(atob(value))); + } + return value as MetadataResponse; + }, + }) + item?: MetadataResponse; + + @property({ type: Boolean, reflect: true }) viewAvailable: boolean = true; + + @property({ type: String }) baseHost = 'archive.org'; + + @property({ type: Boolean }) signedIn = false; + + @property({ type: Array }) menuContents: MenuProviderInterface[] = []; + + @property({ type: Array }) menuShortcuts: MenuShortcutInterface[] = []; + + @property({ type: Boolean, reflect: true, attribute: true }) + viewportInFullscreen: boolean | null = null; + + @property({ type: Boolean, reflect: true }) menuOpened = false; + + @property({ type: String, reflect: true }) openMenu?: MenuId; + + @property({ attribute: false }) modal?: ModalManagerInterface; + + @property({ attribute: false }) + sharedObserver?: SharedResizeObserverInterface; + + @property({ type: Boolean, reflect: true, attribute: true }) loaded: boolean = + false; + + @state() openMenuState: 'overlay' | 'shift' = 'shift'; + + @query('#frame') private frame!: HTMLDivElement; + + @query('slot[name="header"]') private headerSlot!: HTMLSlotElement; + + @query('ia-menu-slider') private menuSlider!: IAMenuSlider; + + @query('button.toggle-menu') private toggleMenuButton!: HTMLButtonElement; + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeResizeObserver(); + } + + updated(changed: PropertyValues): void { + if (changed.has('sharedObserver')) { + const oldObserver = changed.get( + 'sharedObserver', + ) as SharedResizeObserverInterface; + oldObserver?.removeObserver(this.resizeObserverConfig); + this.setResizeObserver(); + } + } + + /** Shared observer */ + handleResize(entry: ResizeObserverEntry): void { + const { width } = entry.contentRect; + if (width <= 600) { + this.openMenuState = 'overlay'; + return; + } + this.openMenuState = 'shift'; + } + + private setResizeObserver(): void { + this.sharedObserver?.addObserver(this.resizeObserverConfig); + this.sharedObserver?.addObserver({ + target: this.headerSlot, + handler: { + handleResize: ({ contentRect }) => { + if (contentRect.height) { + this.requestUpdate(); + } + }, + }, + }); + } + + private removeResizeObserver(): void { + this.sharedObserver?.removeObserver(this.resizeObserverConfig); + } + + get resizeObserverConfig(): { + handler: SharedResizeObserverResizeHandlerInterface; + target: Element; + } { + return { + handler: this, + target: this.frame, + }; + } + /** End shared observer */ + + get loaderTitle(): string { + return this.viewportInFullscreen ? 'Internet Archive' : ''; + } + + get loadingArea(): TemplateResult { + return html` +
+
+ +
+
+ `; + } + + slotChange(e: Event, type: 'header' | 'main'): void { + const slottedContent = ( + e.target as HTMLSlotElement + ).assignedNodes()?.[0] as HTMLElement; + + this.dispatchEvent( + new CustomEvent('slotChange', { + detail: { slot: slottedContent, type }, + }), + ); + this.requestUpdate(); + } + + render(): TemplateResult { + const displayReaderClass = this.loaded ? '' : 'hidden'; + const headerHeight = + (this.headerSlot?.assignedNodes()[0] as HTMLElement)?.offsetHeight || 0; + return html` +
+ this.slotChange(e, 'header')} + > + +
+ `; + } + + get noTheaterView(): TemplateResult { + return html``; + } + + get renderViewport(): TemplateResult | typeof nothing { + if (!this.viewAvailable) { + return this.noTheaterView; + } + + const slotVisibility = !this.loaded ? 'opacity: 0;' : 'opacity: 1;'; + return html` +
+ this.slotChange(e, 'main')} + > +
+ `; + } + + loadingStateUpdated(e: LoadingStateUpdatedEvent): void { + const { loaded } = e.detail; + this.loaded = loaded ?? false; + } + + /** Fullscreen Management */ + manageViewportFullscreen(e: ManageFullscreenEvent): void { + const fullscreenStatus = !!e.detail.isFullScreen; + this.viewportInFullscreen = !fullscreenStatus ? null : fullscreenStatus; + + const event = new CustomEvent('fullscreenToggled', { + detail: e.detail, + }) as ManageFullscreenEvent; + + this.dispatchEvent(event); + } + /** End Fullscreen Management */ + + /** Side menu */ + get shouldRenderMenu(): boolean { + return !!this.menuContents?.length; + } + + toggleMenu(forceValue: boolean | undefined = undefined): void { + this.menuOpened = forceValue !== undefined ? forceValue : !this.menuOpened; + if (this.menuOpened) { + // Move focus to the + this.updateComplete.then(() => { + const closeButton = this.menuSlider?.shadowRoot?.querySelector( + 'button.close', + ) as HTMLElement; + closeButton?.focus(); + }); + } else { + // Move focus back to the menu toggle button + this.updateComplete.then(() => { + this.toggleMenuButton?.focus(); + }); + } + } + + closeMenu(): void { + this.toggleMenu(false); + } + + setOpenMenu(e: ToggleSidePanelOpenEvent): void { + const { id } = e.detail; + this.openMenu = id !== this.openMenu ? id : undefined; + } + + setMenuContents(e: SetSideMenuContentsEvent): void { + const updatedContents = [...e.detail]; + this.menuContents = updatedContents; + } + + setMenuShortcuts(e: SetSideMenuShortcutsEvent): void { + this.menuShortcuts = [...e.detail]; + } + + /** Toggles Side Menu & Sets viewable subpanel */ + manageSideMenuEvents(e: ToggleSideMenuOpenEvent): void { + const { menuId, action } = e.detail; + if (!menuId) { + return; + } + + if (action === 'open') { + this.openShortcut(menuId); + } else if (action === 'toggle') { + this.openMenu = menuId; + this.toggleMenu(); + } + } + + get menuToggleButton(): TemplateResult { + return html` + + `; + } + + get selectedMenuId(): MenuId | '' { + return this.openMenu || ''; + } + + get renderSideMenu(): TemplateResult { + return html` + + `; + } + /** End Side menu */ + + /** Menu Shortcuts */ + openShortcut(selectedMenuId: MenuId = ''): void { + this.openMenu = selectedMenuId; + this.menuOpened = true; + } + + get shortcuts(): TemplateResult { + const shortcuts = this.menuShortcuts.map(({ icon, id, label }) => { + if (id === 'fullscreen') { + return html`${icon}`; + } + + return html` + + `; + }); + return html`
${shortcuts}
`; + } + /** End Menu Shortcuts */ + + /** Misc Render */ + get menuClass(): string { + const hasMenuOrShortcuts = + this.menuContents?.length || this.menuShortcuts?.length; + const drawerState = this.menuOpened && hasMenuOrShortcuts ? 'open' : ''; + const fullscreenState = this.viewportInFullscreen ? 'fullscreen' : ''; + return `${drawerState} ${fullscreenState} ${this.openMenuState}`; + } + + static get styles(): CSSResultGroup { + const subnavWidth = css`var(--item-navigator-menu-width--)`; + const transitionTiming = css`var(--item-navigator-animation-timing--)`; + const transitionEffect = css`transform ${transitionTiming} ease-out`; + const menuMargin = css`var(--item-navigator-menu-margin--)`; + const theaterBg = css`var(--item-navigator-theater-bg-color--)`; + const iconWidth = css`var(--item-navigator-icon-width--)`; + const iconHeight = css`var(--item-navigator-icon-height--)`; + + return [ + themeStyles, + css` + :host { + --item-navigator-menu-width--: var( + --item-navigator-menu-width, + 320px + ); + --item-navigator-animation-timing--: var( + --item-navigator-animation-timing, + 200ms + ); + --item-navigator-menu-margin--: var( + --item-navigator-menu-margin, + 42px + ); + --item-navigator-theater-bg-color--: var( + --item-navigator-theater-bg-color, + #000 + ); + --item-navigator-icon-width--: var( + --item-navigator-icon-width, + 2.4rem + ); + --item-navigator-icon-height--: var( + --item-navigator-icon-height, + 2.4rem + ); + --item-navigator-icon-stroke-color--: var( + --item-navigator-icon-stroke-color, + var(--true-white) + ); + --item-navigator-icon-fill-color--: var( + --item-navigator-icon-fill-color, + var(--true-white) + ); + } + + :host, + #frame, + .menu-and-reader { + position: relative; + overflow: hidden; + display: block; + } + + :host, + #frame, + .loading-area, + .loading-view { + min-height: inherit; + height: inherit; + } + + slot { + display: block; + width: 100%; + } + + slot * { + display: block; + height: inherit; + } + + #frame { + background-color: ${theaterBg}; + color-scheme: dark; + display: flex; + flex-direction: column; + } + + #frame.fullscreen { + position: fixed; + top: 0; + bottom: 0; + left: 0; + right: 0; + z-index: 9; + } + + .loading-view { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .loading-area { + width: 100%; + } + + ia-itemnav-loader { + display: block; + width: 100%; + } + + .hidden { + display: none !important; + } + + button { + cursor: pointer; + padding: 0; + border: 0; + } + + .menu-and-reader { + position: relative; + display: flex; + flex: 1; + } + + nav button { + background: none; + } + + nav .minimized { + background: rgba(0, 0, 0, 0.7); + padding-top: 6px; + position: absolute; + width: ${menuMargin}; + z-index: 2; + left: 0; + border-bottom-right-radius: 5%; + } + + nav .minimized button { + margin-bottom: 0.2rem; + margin: auto; + display: inline-flex; + vertical-align: middle; + align-items: center; + justify-content: center; + width: ${menuMargin}; + height: ${menuMargin}; + } + + nav .minimized button > * { + /** Prevent the icon's SVG description from stealing tooltip message */ + pointer-events: none; + } + + nav .minimized button.toggle-menu > * { + border: 2px solid var(--item-navigator-icon-stroke-color--); + border-radius: ${iconWidth}; + width: ${iconWidth}; + height: ${iconHeight}; + margin: auto; + } + + .toggle-menu .ia-icon { + width: ${iconWidth}; + height: ${iconHeight}; + } + + .ia-icon .fill-color { + fill: var(--item-navigator-icon-fill-color--); + } + + #menu { + position: absolute; + top: 0; + bottom: 0; + left: 0; + z-index: 3; + overflow: hidden; + width: ${subnavWidth}; + transform: translateX(calc(${subnavWidth} * -1)); + transition: ${transitionEffect}; + } + + #reader { + position: relative; + z-index: 1; + transform: translateX(0); + width: 100%; + display: flex; + } + + #reader > * { + width: 100%; + display: flex; + flex: 1; + } + + .open.overlay #reader { + transition: none; + } + + .open #menu { + width: ${subnavWidth}; + transform: translateX(0); + transition: ${transitionEffect}; + } + + .open.shift #reader { + width: calc(100% - ${subnavWidth}); + margin-left: ${subnavWidth}; + transition: ${transitionEffect}; + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/ia-itemnav-loader.test.ts b/src/labs/ia-item-navigator/ia-itemnav-loader.test.ts new file mode 100644 index 0000000..9a5d617 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-itemnav-loader.test.ts @@ -0,0 +1,36 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test } from 'vitest'; + +import { IAItemNavLoader } from './ia-itemnav-loader'; +import './ia-itemnav-loader'; + +describe('IAItemNavLoader', () => { + test('renders the loading spinner', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('svg .ring')).to.exist; + expect(el.shadowRoot?.querySelector('h3')?.textContent).to.contain( + 'Loading viewer', + ); + }); + + test('shows the loader message when provided', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('h2')?.textContent).to.contain( + 'Internet Archive', + ); + }); + + test('omits the message heading when there is no message', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('h2')).to.not.exist; + }); +}); diff --git a/src/labs/ia-item-navigator/ia-itemnav-loader.ts b/src/labs/ia-item-navigator/ia-itemnav-loader.ts new file mode 100644 index 0000000..8c6df83 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-itemnav-loader.ts @@ -0,0 +1,117 @@ +import { + css, + html, + LitElement, + svg, + nothing, + type CSSResultGroup, + type SVGTemplateResult, + type TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; + +/** + * The spinning "loading viewer" placeholder shown inside the navigator frame + * until the slotted theater reports that it has loaded. + */ +@customElement('ia-itemnav-loader') +export class IAItemNavLoader extends LitElement { + @property({ type: String }) loaderMessage = ''; + + get bookIconSvg(): SVGTemplateResult { + return svg` + + + + + + `; + } + + get icon(): SVGTemplateResult { + return this.bookIconSvg; + } + + get loader(): SVGTemplateResult { + return svg` + + Currently loading viewer. + Please wait while we load theater. + + ${this.icon} + + + + `; + } + + render(): TemplateResult { + const title = this.loaderMessage + ? html`

${this.loaderMessage}

` + : nothing; + return html` +
+ ${title} ${this.loader} +

Loading viewer

+
+ `; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + } + + .place-holder { + width: 30%; + margin: auto; + text-align: center; + color: var(--item-navigator-text-color--); + position: relative; + } + + .place-holder svg { + display: block; + width: 60%; + max-width: 100px; + height: auto; + margin: auto; + } + + svg * { + fill: var(--item-navigator-text-color--); + } + + svg .ring { + animation: rotate 1.3s infinite linear; + transform-origin: 50px 50px; + transform-box: fill-box; + display: block; /* transform won't work on inline style */ + } + + @keyframes rotate { + 0% { + transform: rotate(-360deg); + } + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/ia-menu-button.test.ts b/src/labs/ia-item-navigator/ia-menu-button.test.ts new file mode 100644 index 0000000..822c960 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-menu-button.test.ts @@ -0,0 +1,71 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test, vi } from 'vitest'; + +import { IAMenuButton } from './ia-menu-button'; +import './ia-menu-button'; + +describe('IAMenuButton', () => { + test('renders a + `; + } + + render(): TemplateResult { + return this.href ? this.linkButton : this.clickButton; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + --item-navigator-active-button-bg--: var( + --item-navigator-active-button-bg, + var(--mid-gray) + ); + --item-navigator-menu-button-label-display--: var( + --item-navigator-menu-button-label-display, + none + ); + --item-navigator-icon-inactive-fill--: var( + --item-navigator-icon-inactive-fill, + var(--lighter-gray) + ); + --item-navigator-icon-active-fill--: var( + --item-navigator-icon-active-fill, + var(--true-white) + ); + } + + a { + display: inline-block; + text-decoration: none; + } + + button.menu-item { + -webkit-appearance: none; + appearance: none; + } + + .menu-item { + display: inline-flex; + width: 100%; + padding: 0; + font-size: 1.6rem; + text-align: left; + background: transparent; + align-items: center; + border: none; + cursor: pointer; + transition: background-color 0.2s; + border-radius: 6px; + } + + .menu-item:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + .label { + display: var(--item-navigator-menu-button-label-display--); + padding: 0; + font-weight: 400; + color: var(--item-navigator-text-color--); + text-align: left; + vertical-align: middle; + margin-left: 1rem; + } + + .menu-details { + color: var(--item-navigator-text-color--); + display: inline-block; + margin-left: 0.5rem; + font-style: italic; + font-size: 1.5rem; + } + + .menu-item > .icon { + position: relative; + display: inline-flex; + z-index: 2; + min-width: 4.2rem; + max-width: 4.2rem; + height: 4.2rem; + vertical-align: middle; + align-items: center; + justify-content: center; + } + + .menu-item > .icon > * { + /* Prevent tooltip containing icon literal description */ + pointer-events: none; + } + + .menu-item[aria-expanded='true'] .icon { + background-color: var(--item-navigator-active-button-bg--); + border-radius: 1rem 0 0 1rem; + } + + .icon .fill-color { + fill: var(--item-navigator-icon-inactive-fill--); + } + + .icon.active .fill-color { + fill: var(--item-navigator-icon-active-fill--); + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/ia-menu-slider.test.ts b/src/labs/ia-item-navigator/ia-menu-slider.test.ts new file mode 100644 index 0000000..f4986d4 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-menu-slider.test.ts @@ -0,0 +1,108 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test, vi } from 'vitest'; + +import { IAMenuSlider } from './ia-menu-slider'; +import './ia-menu-slider'; +import type { MenuProviderInterface } from './interfaces/menu-interfaces'; + +function provider( + id: string, + overrides: Partial = {}, +): MenuProviderInterface { + return { + id, + label: `${id} label`, + icon: html`${id}`, + item: { metadata: { identifier: 'test-item' } } as never, + baseHost: 'archive.org', + subPrefix: '', + component: html`
${id} body
`, + ...overrides, + }; +} + +async function sliderWith( + menus: MenuProviderInterface[], +): Promise { + const el = await fixture( + html``, + ); + el.menus = menus; + await el.updateComplete; + return el; +} + +describe('IAMenuSlider', () => { + test('renders one menu button per provider', async () => { + const el = await sliderWith([provider('a'), provider('b'), provider('c')]); + expect(el.shadowRoot?.querySelectorAll('ia-menu-button')).to.have.lengthOf( + 3, + ); + }); + + test('renders a close button', async () => { + const el = await sliderWith([provider('a')]); + expect(el.shadowRoot?.querySelector('button.close')).to.exist; + }); + + test('selecting a menu opens its panel; re-selecting it closes it', async () => { + const el = await sliderWith([provider('a'), provider('b')]); + + el.setSelectedMenu(new CustomEvent('x', { detail: { id: 'a' } })); + await el.updateComplete; + expect(el.selectedMenu).to.equal('a'); + expect(el.selectedMenuClass).to.equal('open'); + + el.setSelectedMenu(new CustomEvent('x', { detail: { id: 'a' } })); + await el.updateComplete; + expect(el.selectedMenu).to.equal(''); + expect(el.selectedMenuClass).to.equal(''); + }); + + test('renders the selected provider body', async () => { + const el = await sliderWith([provider('a'), provider('b')]); + el.selectedMenu = 'b'; + await el.updateComplete; + + const body = el.shadowRoot?.querySelector('.selected-menu .panel-body'); + expect(body?.textContent).to.contain('b body'); + }); + + test('closeMenu emits menuSliderClosed; manuallyHandleClose keeps it open', async () => { + const el = await sliderWith([provider('a')]); + el.manuallyHandleClose = true; + await el.updateComplete; + + const listener = vi.fn(); + el.addEventListener('menuSliderClosed', listener); + + el.closeMenu(); + expect(listener).toHaveBeenCalledOnce(); + expect(el.open).to.equal(true); // left for the host to close + + el.manuallyHandleClose = false; + el.closeMenu(); + expect(el.open).to.equal(false); + }); + + test('Escape closes the open panel first, then the drawer', async () => { + const el = await sliderWith([provider('a')]); + el.selectedMenu = 'a'; + await el.updateComplete; + + const closed = vi.fn(); + el.addEventListener('menuSliderClosed', closed); + const main = el.shadowRoot?.querySelector('.main') as HTMLElement; + + // First Escape closes the panel, not the drawer. + main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + await el.updateComplete; + expect(el.selectedMenu).to.equal(''); + expect(closed).not.toHaveBeenCalled(); + + // Second Escape closes the drawer. + main.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(closed).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/labs/ia-item-navigator/ia-menu-slider.ts b/src/labs/ia-item-navigator/ia-menu-slider.ts new file mode 100644 index 0000000..3ea502d --- /dev/null +++ b/src/labs/ia-item-navigator/ia-menu-slider.ts @@ -0,0 +1,375 @@ +import { + css, + html, + LitElement, + nothing, + TemplateResult, + type CSSResultGroup, +} from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; +import { collapseSidebarIcon } from './icons'; +import './ia-menu-button'; +import { MenuProviderInterface } from './interfaces/menu-interfaces'; + +const sliderEvents = { + closeDrawer: 'menuSliderClosed', +}; + +/** + * The sliding drawer that lists the navigator's menu providers and renders the + * body of whichever provider is currently selected. + */ +@customElement('ia-menu-slider') +export class IAMenuSlider extends LitElement { + @property({ type: Array }) menus: MenuProviderInterface[] = []; + + @property({ type: Boolean }) open = false; + + @property({ type: Boolean }) manuallyHandleClose = false; + + @property({ type: String }) selectedMenu = ''; + + @property({ type: Object }) selectedMenuAction: + | TemplateResult + | typeof nothing = nothing; + + @property({ type: Boolean }) animateMenuOpen = false; + + @query('.content.open button.close') contentCloseButton!: HTMLElement; + + @query('.menu-list') menuList!: HTMLUListElement; + + updated(): void { + const actionButton = this.selectedMenuDetails?.actionButton || nothing; + const actionButtonHasChanged = actionButton !== this.selectedMenuAction; + if (actionButtonHasChanged) { + this.selectedMenuAction = actionButton; + } + } + + /** + * Event handler, captures state of selected menu + */ + setSelectedMenu({ detail }: CustomEvent): void { + const { id } = detail; + this.selectedMenu = this.selectedMenu === id ? '' : id; + this.selectedMenuAction = this.selectedMenuDetails?.actionButton || nothing; + this.updateComplete.then(() => { + this.contentCloseButton?.focus(); + }); + } + + /** + * closes menu drawer + */ + closeMenu(): void { + if (!this.manuallyHandleClose) { + this.open = false; + } + const { closeDrawer } = sliderEvents; + const drawerClosed = new CustomEvent(closeDrawer, { + detail: this.selectedMenuDetails, + }); + this.dispatchEvent(drawerClosed); + } + + closePanel(): void { + const menuId = this.selectedMenu; + this.selectedMenu = ''; + this.selectedMenuAction = nothing; + + // Return focus to the menu button that was previously selected + if (menuId) { + this.updateComplete.then(() => { + const menuIndex = this.menus.findIndex((menu) => menu.id === menuId); + if (menuIndex !== -1) { + const menuButton = this.menuList.querySelector( + `li:nth-child(${menuIndex + 1}) ia-menu-button`, + ) as HTMLElement; + menuButton?.focus(); + } + }); + } + } + + /** + * Handle keyboard events, specifically ESC key to close menu details + */ + handleKeyDown(event: KeyboardEvent): void { + if (event.key === 'Escape') { + event.preventDefault(); + if (this.selectedMenu) { + this.closePanel(); + } else { + this.closeMenu(); + } + } + } + + get selectedMenuDetails(): MenuProviderInterface | undefined { + return this.menus.find((menu) => menu.id === this.selectedMenu); + } + + /* render */ + + get sliderDetailsClass(): string { + const animate = this.animateMenuOpen ? 'animate' : ''; + const state = this.open ? 'open' : ''; + return `${animate} ${state}`; + } + + get selectedMenuClass(): string { + return this.selectedMenu ? 'open' : ''; + } + + get menuItems(): TemplateResult[] { + return this.menus.map( + (menu) => html` +
  • + +
  • + `, + ); + } + + get renderMenuHeader(): TemplateResult { + const { label = '', menuDetails = '' } = this.selectedMenuDetails || {}; + const headerClass = this.selectedMenuAction ? 'with-secondary-action' : ''; + const actionBlock = this.selectedMenuAction + ? html`${this.selectedMenuAction}` + : nothing; + return html` +
    +
    +

    ${label}

    + ${menuDetails} +
    + ${actionBlock} + +
    + `; + } + + get closeButton(): TemplateResult { + return html` + + `; + } + + /** @inheritdoc */ + render(): TemplateResult { + return html` +
    + +
    + `; + } + + static get styles(): CSSResultGroup { + const menuButtonWidth = css`42px`; + const sliderWidth = css`var(--item-navigator-menu-width--)`; + const transitionTiming = css`var(--item-navigator-animation-timing--)`; + + return [ + themeStyles, + css` + :host { + --item-navigator-menu-width--: var( + --item-navigator-menu-width, + 320px + ); + --item-navigator-animation-timing--: var( + --item-navigator-animation-timing, + 200ms + ); + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + --item-navigator-menu-slider-bg--: var( + --item-navigator-menu-slider-bg, + #212121 + ); + --item-navigator-active-button-bg--: var( + --item-navigator-active-button-bg, + var(--mid-gray) + ); + --item-navigator-subpanel-border-color--: var( + --item-navigator-subpanel-border-color, + #4b4b4b + ); + --item-navigator-header-icon-width--: var( + --item-navigator-header-icon-width, + 2.4rem + ); + --item-navigator-header-icon-height--: var( + --item-navigator-header-icon-height, + 2.4rem + ); + --item-navigator-icon-fill-color--: var( + --item-navigator-icon-fill-color, + var(--true-white) + ); + } + + .main { + overflow: hidden; + width: 100%; + height: 100%; + } + + .animate { + transition: transform ${transitionTiming} ease-out; + } + + .menu { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: ${sliderWidth}; + padding: 0.5rem 0.5rem 0 0; + box-sizing: border-box; + font-size: 1.4rem; + color: var(--item-navigator-text-color--); + background: var(--item-navigator-menu-slider-bg--); + transform: translateX(calc(${sliderWidth} * -1)); + } + + button { + cursor: pointer; + } + + header { + margin: 0 0 0.5rem 0; + } + + header * { + margin: 0; + display: inline-block; + } + + header button { + cursor: pointer; + } + + header.with-secondary-action .details { + width: 80%; + } + + header .details { + font-weight: bold; + width: 88%; + } + + header .custom-action > *, + button.close { + padding: 0; + background-color: transparent; + border: 0; + } + + header .custom-action, + button.close { + position: absolute; + } + + button.close { + min-width: 38px; + min-height: 38px; + display: flex; + justify-content: center; + align-items: center; + right: 0; + top: 0; + } + + button.close .ia-icon { + width: var(--item-navigator-header-icon-width--); + height: var(--item-navigator-header-icon-height--); + } + + .ia-icon .fill-color { + fill: var(--item-navigator-icon-fill-color--); + } + + .content { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: ${menuButtonWidth}; + z-index: 1; + transform: translateX(calc(${sliderWidth} * -1)); + transition: transform ${transitionTiming} ease-out; + background: var(--item-navigator-active-button-bg--); + border-right: 0.2rem solid; + border-color: var(--item-navigator-subpanel-border-color--); + padding: 0.5rem 0 0 0.5rem; + display: flex; + flex-direction: column; + } + + .open { + transform: translateX(0); + } + + .menu-list { + padding: 0; + margin: 0; + list-style: none; + background: var(--item-navigator-menu-slider-bg--); + } + + .menu-list li { + margin-bottom: 0.2rem; + } + + .content > section { + overflow: auto; + overscroll-behavior: contain; + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/ia-no-theater-available.test.ts b/src/labs/ia-item-navigator/ia-no-theater-available.test.ts new file mode 100644 index 0000000..41763cc --- /dev/null +++ b/src/labs/ia-item-navigator/ia-no-theater-available.test.ts @@ -0,0 +1,45 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test, vi } from 'vitest'; + +import { IANoTheaterAvailable } from './ia-no-theater-available'; +import './ia-no-theater-available'; + +describe('IANoTheaterAvailable', () => { + test('renders the placeholder message', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('h2')?.textContent).to.contain( + 'NO PREVIEW AVAILABLE', + ); + }); + + test('links to the item download page', async () => { + const el = await fixture( + html``, + ); + expect(el.downloadUrl).to.equal('/download/my-item'); + expect(el.shadowRoot?.querySelector('a')?.getAttribute('href')).to.equal( + '/download/my-item', + ); + }); + + test('emits loadingStateUpdated once the identifier is set', async () => { + const el = await fixture( + html``, + ); + const listener = vi.fn(); + el.addEventListener('loadingStateUpdated', listener); + + el.identifier = 'abc'; + await el.updateComplete; + + expect(listener).toHaveBeenCalledOnce(); + expect((listener.mock.calls[0][0] as CustomEvent).detail.loaded).to.equal( + true, + ); + }); +}); diff --git a/src/labs/ia-item-navigator/ia-no-theater-available.ts b/src/labs/ia-item-navigator/ia-no-theater-available.ts new file mode 100644 index 0000000..2bcd460 --- /dev/null +++ b/src/labs/ia-item-navigator/ia-no-theater-available.ts @@ -0,0 +1,100 @@ +import { + LitElement, + html, + TemplateResult, + PropertyValues, + css, + type CSSResultGroup, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; + +/** + * Placeholder shown in place of the theater when `viewAvailable` is false, + * i.e. the item has no files that can be experienced in the browser. Emits + * `loadingStateUpdated` so the navigator hides its loading spinner. + */ +@customElement('ia-no-theater-available') +export class IANoTheaterAvailable extends LitElement { + @property({ type: String }) identifier?: string = ''; + + emitLoaded(): void { + this.dispatchEvent( + new CustomEvent<{ loaded: boolean }>('loadingStateUpdated', { + detail: { loaded: true }, + }), + ); + } + + updated(changed: PropertyValues): void { + if (changed.has('identifier')) { + this.emitLoaded(); + } + } + + get downloadUrl(): string { + return `/download/${this.identifier}`; + } + + render(): TemplateResult { + return html` +
    +

    THERE IS NO PREVIEW AVAILABLE FOR THIS ITEM

    +

    + This item does not appear to have any files that can be experienced on + Archive.org.
    + Please download files in this item to interact with them on your + computer. +

    + Show all files +
    + `; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + color: var(--item-navigator-text-color--); + text-align: center; + } + + section { + width: 100%; + margin: 5%; + padding: 0 5%; + } + + p { + font-size: 1.4rem; + } + + a { + color: var(--item-navigator-text-color--); + background-color: var(--navy-blue); + min-height: 35px; + cursor: pointer; + line-height: normal; + border-radius: 0.4rem; + text-align: center; + vertical-align: middle; + font-size: 1.4rem; + font-family: var(--base-font-family); + display: inline-block; + padding: 0.85rem 1.2rem; + border: 1px solid var(--lightest-gray); + white-space: nowrap; + appearance: auto; + box-sizing: border-box; + user-select: none; + text-decoration: none; + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/icons.ts b/src/labs/ia-item-navigator/icons.ts new file mode 100644 index 0000000..8981f6e --- /dev/null +++ b/src/labs/ia-item-navigator/icons.ts @@ -0,0 +1,43 @@ +import { html } from 'lit'; + +/** + * Icons inlined from the `@internetarchive/icon-ellipses` and + * `@internetarchive/icon-collapse-sidebar` packages (v1.4.1). They are inlined + * as templates — rather than added as dependencies — because the navigator + * only needs these two glyphs, and inlining lets each host component style the + * `.fill-color` path directly via its own CSS custom properties. The `svg` is + * marked `aria-hidden` at the call site; each icon is decorative next to a + * labelled control. + */ + +/** Three-dot "more options" glyph used by the menu toggle button. */ +export const ellipsesIcon = html` + +`; + +/** Circular "collapse sidebar" glyph used by the menu/panel close buttons. */ +export const collapseSidebarIcon = html` + +`; diff --git a/src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts b/src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts new file mode 100644 index 0000000..70ea1a5 --- /dev/null +++ b/src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts @@ -0,0 +1,34 @@ +import { LitElement } from 'lit'; +import { MetadataResponse } from '@internetarchive/metadata-service'; +import { + MenuProviderInterface, + MenuShortcutInterface, +} from './menu-interfaces'; +import { + ModalManagerInterface, + SharedResizeObserverInterface, +} from './service-interfaces'; + +/** + * The contract a theater host implements to feed menu providers and shortcuts + * to the item navigator. + */ +export interface CustomTheaterInterface extends LitElement { + baseHost?: string; + itemMD?: MetadataResponse; + menuProviders?: MenuProviderInterface[]; + menuShortcuts?: MenuShortcutInterface[]; + sideMenuOpen: boolean; + + signedIn?: boolean | null; + + sharedObserver?: SharedResizeObserverInterface; + modal?: ModalManagerInterface; + + emitLoadingStatusUpdate: (loaded: boolean) => void; + + addMenuShortcut: (menuId: string) => void; + removeMenuShortcut: (menuId: string) => void; + sortMenuShortcuts: () => void; + emitMenuShortcutsUpdated: () => void; +} diff --git a/src/labs/ia-item-navigator/interfaces/event-interfaces.ts b/src/labs/ia-item-navigator/interfaces/event-interfaces.ts new file mode 100644 index 0000000..741f71a --- /dev/null +++ b/src/labs/ia-item-navigator/interfaces/event-interfaces.ts @@ -0,0 +1,50 @@ +import { + MenuProviderInterface, + MenuShortcutInterface, + MenuId, +} from './menu-interfaces'; + +/** Toggles Menu && Sets open panel */ +export interface ToggleSideMenuOpenEvent extends CustomEvent { + type: 'updateSideMenu'; + detail: { + menuId: MenuId | undefined | ''; + action: 'open' | 'toggle' | ''; + }; +} + +/** Sets open panel */ +export interface ToggleSidePanelOpenEvent extends CustomEvent { + type: 'menuTypeSelected'; + detail: { + id: MenuId | ''; + }; +} + +/** Sets menu order that is displayed */ +export interface SetSideMenuContentsEvent extends CustomEvent { + type: 'menuUpdated'; + detail: MenuProviderInterface[]; +} + +/** Sets menu shortcuts that is displayed */ +export interface SetSideMenuShortcutsEvent extends CustomEvent { + type: 'menuUpdated'; + detail: MenuShortcutInterface[]; +} + +/** Toggles fullscreen mode */ +export interface ManageFullscreenEvent extends CustomEvent { + type: 'ViewportInFullScreen'; + detail: { + isFullScreen: boolean; + }; +} + +/** Toggles loading view */ +export interface LoadingStateUpdatedEvent extends CustomEvent { + type: 'loadingStateUpdated'; + detail: { + loaded: boolean; + }; +} diff --git a/src/labs/ia-item-navigator/interfaces/menu-interfaces.ts b/src/labs/ia-item-navigator/interfaces/menu-interfaces.ts new file mode 100644 index 0000000..a7929fd --- /dev/null +++ b/src/labs/ia-item-navigator/interfaces/menu-interfaces.ts @@ -0,0 +1,25 @@ +import { TemplateResult } from 'lit'; +import { MetadataResponse } from '@internetarchive/metadata-service'; + +export type MenuId = string; +export interface MenuShortcutInterface { + icon: TemplateResult; + id: MenuId; + label: string; +} + +export interface MenuDetailsInterface extends MenuShortcutInterface { + menuDetails?: TemplateResult; + selected?: boolean; + followable?: boolean; + href?: string; + component?: TemplateResult; +} + +export interface MenuProviderInterface extends MenuDetailsInterface { + item: MetadataResponse; + baseHost: string; + subPrefix: string; + updated?: unknown; + actionButton?: TemplateResult; +} diff --git a/src/labs/ia-item-navigator/interfaces/service-interfaces.ts b/src/labs/ia-item-navigator/interfaces/service-interfaces.ts new file mode 100644 index 0000000..e96c825 --- /dev/null +++ b/src/labs/ia-item-navigator/interfaces/service-interfaces.ts @@ -0,0 +1,38 @@ +/** + * Minimal structural interfaces for services that the host injects into the + * item navigator. The upstream `@internetarchive/iaux-item-navigator` imported + * these types from `@internetarchive/modal-manager` and + * `@internetarchive/shared-resize-observer`, but the navigator only ever holds + * / forwards these instances — it never constructs them or relies on anything + * beyond the small surface below. Declaring local interfaces keeps those two + * packages out of the dependency tree (they were type-only imports) so + * consumers aren't forced to install them. + */ + +/** Handler invoked by a shared resize observer when its target resizes. */ +export interface SharedResizeObserverResizeHandlerInterface { + handleResize(entry: ResizeObserverEntry): void; +} + +/** A single target/handler registration on a shared resize observer. */ +export interface SharedResizeObserverConfig { + target: Element; + handler: SharedResizeObserverResizeHandlerInterface; +} + +/** + * The subset of `@internetarchive/shared-resize-observer` the navigator uses: + * an object it can add/remove target-handler pairs on. + */ +export interface SharedResizeObserverInterface { + addObserver(config: SharedResizeObserverConfig): void; + removeObserver(config: SharedResizeObserverConfig): void; +} + +/** + * The navigator only holds a reference to the host's modal manager element and + * hands it to menu providers; it never calls it directly. Typing it as an + * `HTMLElement` captures that contract without depending on + * `@internetarchive/modal-manager`. + */ +export type ModalManagerInterface = HTMLElement; diff --git a/src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts b/src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts new file mode 100644 index 0000000..3a8cd29 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts @@ -0,0 +1,103 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test, vi } from 'vitest'; + +import { IASortFilesButton } from './ia-sort-files-button'; +import './ia-sort-files-button'; +import type { ViewableFileInfo } from './models'; + +function file(title: string, origSort: number): ViewableFileInfo { + return { + title, + orig_sort: origSort, + url_path: `/details/${title}`, + image: '', + author: '', + file_subprefix: title, + file_source: `${title}.pdf`, + file_prefix: title, + }; +} + +describe('IASortFilesButton', () => { + test('starts in the default (neutral) state', async () => { + const el = await fixture( + html``, + ); + expect(el.sortOrderBy).to.equal('default'); + expect(el.shadowRoot?.querySelector('.sort-by.neutral-icon')).to.exist; + }); + + test('cycles default → ascending → descending → default on click', async () => { + const el = await fixture( + html``, + ); + + const clickButton = async () => { + ( + el.shadowRoot?.querySelector('button.sort-by') as HTMLButtonElement + ).click(); + await el.updateComplete; + }; + + await clickButton(); + expect(el.sortOrderBy).to.equal('title_asc'); + expect(el.shadowRoot?.querySelector('.sort-by.asc-icon')).to.exist; + + await clickButton(); + expect(el.sortOrderBy).to.equal('title_desc'); + expect(el.shadowRoot?.querySelector('.sort-by.desc-icon')).to.exist; + + await clickButton(); + expect(el.sortOrderBy).to.equal('default'); + expect(el.shadowRoot?.querySelector('.sort-by.neutral-icon')).to.exist; + }); + + test('sorts the file list and emits fileListSorted with the ordered files', async () => { + const el = await fixture( + html``, + ); + el.fileListRaw = [file('Beta', 1), file('Alpha', 0), file('Gamma', 2)]; + await el.updateComplete; + + const listener = vi.fn(); + el.addEventListener('fileListSorted', listener); + + el.sortVolumes('title_asc'); + + expect(listener).toHaveBeenCalledOnce(); + const event = listener.mock.calls[0][0] as CustomEvent; + expect(event.composed).to.equal(true); + expect(event.detail.sortType).to.equal('title_asc'); + expect( + event.detail.sortedFiles.map((f: ViewableFileInfo) => f.title), + ).to.deep.equal(['Alpha', 'Beta', 'Gamma']); + }); + + test('restores the original order when sorting back to default', async () => { + const el = await fixture( + html``, + ); + el.fileListRaw = [file('Beta', 1), file('Alpha', 0), file('Gamma', 2)]; + await el.updateComplete; + + el.sortVolumes('default'); + expect(el.fileListSorted.map((f) => f.title)).to.deep.equal([ + 'Alpha', + 'Beta', + 'Gamma', + ]); + }); + + test('does not mutate the source list in place', async () => { + const el = await fixture( + html``, + ); + const raw = [file('Beta', 1), file('Alpha', 0)]; + el.fileListRaw = raw; + await el.updateComplete; + + el.sortVolumes('title_asc'); + expect(raw.map((f) => f.title)).to.deep.equal(['Beta', 'Alpha']); + }); +}); diff --git a/src/labs/ia-item-navigator/menus/ia-sort-files-button.ts b/src/labs/ia-item-navigator/menus/ia-sort-files-button.ts new file mode 100644 index 0000000..6b3c4ef --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-sort-files-button.ts @@ -0,0 +1,190 @@ +import { + css, + html, + LitElement, + type CSSResultGroup, + TemplateResult, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; +import type { FileSortOption, ViewableFileInfo } from './models'; + +const sortAscIcon = html` + + + + + + + +`; + +const sortDescIcon = html` + + + + + + + +`; + +const sortNeutralIcon = html` + + + + + + + +`; + +/** + * A tri-state sort toggle for the viewable-files panel header. Cycles + * default → title ascending → title descending → default, sorting the supplied + * file list and emitting `fileListSorted` on each change. + */ +@customElement('ia-sort-files-button') +export class IASortFilesButton extends LitElement { + @property({ type: Array }) fileListRaw: ViewableFileInfo[] = []; + + @property({ type: Array }) fileListSorted: ViewableFileInfo[] = []; + + @property({ type: String, reflect: true }) sortOrderBy: FileSortOption = + 'default'; + + render(): TemplateResult { + return html`
    ${this.sortButton}
    `; + } + + get sortButton(): TemplateResult { + const sortIcons: Record = { + default: html` + + `, + title_asc: html` + + `, + title_desc: html` + + `, + }; + + return sortIcons[this.sortOrderBy]; + } + + sortVolumes(sortType: FileSortOption): void { + this.sortOrderBy = sortType; + + const sortedFiles = [...this.fileListRaw].sort((a, b) => { + if (sortType === 'title_asc') return a.title.localeCompare(b.title); + if (sortType === 'title_desc') return b.title.localeCompare(a.title); + return (a.orig_sort ?? 0) - (b.orig_sort ?? 0); + }); + + this.dispatchEvent( + new CustomEvent('fileListSorted', { + detail: { + sortType, + sortedFiles, + }, + bubbles: true, + composed: true, + }), + ); + this.fileListSorted = sortedFiles; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-header-icon-width--: var( + --item-navigator-header-icon-width, + 18px + ); + --item-navigator-header-icon-height--: var( + --item-navigator-header-icon-height, + 18px + ); + --item-navigator-icon-fill-color--: var( + --item-navigator-icon-fill-color, + var(--true-white) + ); + } + + button.sort-by { + padding: 0; + background-color: transparent; + border: 0; + cursor: pointer; + color: var(--item-navigator-icon-fill-color--); + display: inline-flex; + } + + button.sort-by svg { + width: var(--item-navigator-header-icon-width--); + height: var(--item-navigator-header-icon-height--); + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts b/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts new file mode 100644 index 0000000..66f7110 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts @@ -0,0 +1,102 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test } from 'vitest'; + +import { IAViewableFilesPanel } from './ia-viewable-files-panel'; +import './ia-viewable-files-panel'; +import type { ViewableFileInfo } from './models'; + +function file( + overrides: Partial & { title: string }, +): ViewableFileInfo { + return { + url_path: `/details/${overrides.title}`, + image: '', + author: '', + file_subprefix: overrides.title, + file_source: `${overrides.title}.txt`, + file_prefix: overrides.title, + ...overrides, + }; +} + +describe('IAViewableFilesPanel', () => { + test('renders nothing when the file list is empty', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('ul')).to.not.exist; + }); + + test('renders one entry per file with its title', async () => { + const el = await fixture( + html``, + ); + el.fileList = [file({ title: 'Volume 1' }), file({ title: 'Volume 2' })]; + await el.updateComplete; + + const items = el.shadowRoot?.querySelectorAll('li'); + expect(items).to.have.lengthOf(2); + expect(items?.[0].textContent).to.contain('Volume 1'); + }); + + test('marks the entry matching the current subPrefix as active', async () => { + const el = await fixture( + html``, + ); + el.fileList = [ + file({ title: 'V1', file_subprefix: 'v1' }), + file({ title: 'V2', file_subprefix: 'v2' }), + ]; + el.subPrefix = 'v2'; + await el.updateComplete; + + const active = el.shadowRoot?.querySelectorAll('.content.active'); + expect(active).to.have.lengthOf(1); + expect(active?.[0].textContent).to.contain('V2'); + }); + + test('flags PDF sources with a PDF label', async () => { + const el = await fixture( + html``, + ); + el.fileList = [ + file({ title: 'A book', file_source: 'book.pdf' }), + file({ title: 'A text', file_source: 'plain.txt' }), + ]; + await el.updateComplete; + + const labels = el.shadowRoot?.querySelectorAll('.pdf-label'); + expect(labels).to.have.lengthOf(1); + }); + + test('builds file URLs against the base host', async () => { + const el = await fixture( + html``, + ); + const url = el.fileUrl(file({ title: 'x', url_path: '/details/x' })); + expect(url).to.equal('//example.org/details/x'); + }); + + test('appends the sort order to URLs only when addSortToUrl is set', async () => { + const el = await fixture( + html``, + ); + el.addSortToUrl = true; + el.sortOrderBy = 'title_asc'; + await el.updateComplete; + + expect(el.fileUrl(file({ title: 'x', url_path: '/details/x' }))).to.equal( + '//example.org/details/x?sort=title_asc', + ); + + el.sortOrderBy = 'default'; + expect(el.fileUrl(file({ title: 'x', url_path: '/details/x' }))).to.equal( + '//example.org/details/x', + ); + }); +}); diff --git a/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts b/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts new file mode 100644 index 0000000..1576549 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts @@ -0,0 +1,219 @@ +import { + css, + html, + LitElement, + nothing, + TemplateResult, + type CSSResultGroup, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; +import themeStyles from '@src/themes/theme-styles'; +import type { FileSortOption, ViewableFileInfo } from './models'; + +/** Icon for the viewable-files menu shortcut / menu entry. */ +export const viewableFilesIcon = html` + + Viewable Files + + + + +`; + +/** + * The "viewable files" side panel: a scrollable list of the item's viewable + * files/volumes, linking each to its page on the host and highlighting the one + * matching the current `subPrefix`. Populated by the host via `fileList` + * (typically re-ordered by an `ia-sort-files-button`). + */ +@customElement('ia-viewable-files-panel') +export class IAViewableFilesPanel extends LitElement { + @property({ type: String }) baseHost: string = 'archive.org'; + + @property({ type: String }) sortOrderBy: FileSortOption = 'default'; + + @property({ type: String }) subPrefix: string = ''; + + @property({ type: Array }) fileList: ViewableFileInfo[] = []; + + @property({ type: Boolean, reflect: true }) addSortToUrl = false; + + firstUpdated(): void { + const activeFile = + this.shadowRoot?.querySelector('.content.active'); + // allow for css animations to run before scrolling to active file + setTimeout(() => { + // `scrollIntoViewIfNeeded` only auto-scrolls when the element is out of + // view (Chrome, Safari); `scrollIntoView` is the cross-browser fallback. + const scrollable = activeFile as HTMLElement & { + scrollIntoViewIfNeeded?: (center: boolean) => void; + }; + if (scrollable?.scrollIntoViewIfNeeded) { + scrollable.scrollIntoViewIfNeeded(true); + } else { + activeFile?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + }, 350); + } + + fileUrl(item: ViewableFileInfo): string { + const baseUrl = `//${this.baseHost}${item.url_path}`; + if (this.addSortToUrl && this.sortOrderBy !== 'default') { + return `${baseUrl}?sort=${this.sortOrderBy}`; + } + return baseUrl; + } + + get pdfLabel(): TemplateResult { + return html`view this PDF`; + } + + fileLi(item: ViewableFileInfo): TemplateResult { + const activeClass = this.subPrefix === item.file_subprefix ? ' active' : ''; + const hrefUrl = this.fileUrl(item); + const isPdf = (item.file_source ?? '').match(/^[^+]+\.pdf$/i); + + return html` +
  • +
    + +
  • + `; + } + + get fileListTemplate(): TemplateResult { + const filesDisplay = repeat( + this.fileList, + (file) => file?.file_prefix, + this.fileLi.bind(this), + ); + return html` +
      + ${filesDisplay} +
      +
    + `; + } + + render(): TemplateResult { + return html`${this.fileList.length ? this.fileListTemplate : nothing}`; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + --item-navigator-separator-color--: var( + --item-navigator-separator-color, + #4b4b4b + ); + --item-navigator-active-file-border-color--: var( + --item-navigator-active-file-border-color, + #538bc5 + ); + + display: block; + overflow-y: auto; + box-sizing: border-box; + color: var(--item-navigator-text-color--); + margin-top: 14px; + margin-bottom: 2rem; + --active-border-width--: 2px; + } + + a { + color: var(--item-navigator-text-color--); + text-decoration: none; + } + + ul { + padding: 0; + list-style: none; + margin: var(--active-border-width--) 0.5rem 1rem 0; + } + + ul > li:first-child .separator { + display: none; + } + + li { + cursor: pointer; + position: relative; + } + + li .content { + border: var(--active-border-width--) solid transparent; + padding: 0.2rem 0 0.4rem 0.2rem; + } + + li .content.active { + border: var(--active-border-width--) solid + var(--item-navigator-active-file-border-color--); + } + + li.content a { + display: flex; + } + + .item-title { + margin-block-start: 0em; + margin-block-end: 0em; + font-size: 14px; + font-weight: bold; + word-wrap: break-word; + padding-left: 5px; + } + + .separator { + background-color: var(--item-navigator-separator-color--); + width: 98%; + margin: 1px auto; + height: 1px; + } + + .pdf-label { + border: 1px solid; + padding: 2px 5px; + border-radius: 20px; + display: inline-block; + margin-left: 5px; + font-size: 0.9rem; + } + + .pdf-label .sr-only { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + padding: 0; + border: 0; + height: 1px; + width: 1px; + overflow: hidden; + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/menus/models.ts b/src/labs/ia-item-navigator/menus/models.ts new file mode 100644 index 0000000..b2e3047 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/models.ts @@ -0,0 +1,17 @@ +/** Sort orders offered by the viewable-files panel's sort button. */ +export type FileSortOption = 'default' | 'title_asc' | 'title_desc'; + +/** A single viewable file / volume entry rendered in the viewable-files panel. */ +export interface ViewableFileInfo { + url_path: string; + image: string; + title: string; + author: string; + file_subprefix: string; + file_source: string; + file_origin?: string; + /** Used as the `repeat` key when rendering the list. */ + file_prefix?: string; + /** Original ordering index, used to restore the default sort. */ + orig_sort?: number; +} From dfd1b283f4cabe14470fe7e1ef6e26371e0aad9e Mon Sep 17 00:00:00 2001 From: Isa HV Date: Mon, 20 Jul 2026 13:05:36 -0700 Subject: [PATCH 02/17] WEBDEV-8458 Add share panel to migrated item-navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the migration by porting the social share panel (from iaux-in-share-panel) as ia-share-panel, so the item-navigator's bundled menus are fully migrated. - Rename to ia-share-panel / IASharePanel; export shareIcon as the provider icon. - Inline all 7 social/link icons (share, twitter, facebook, tumblr, pinterest, email, link) as .fill-color templates in menus/share-icons.ts — no icon-package deps. - Upgrade the deprecated document.execCommand('copy') to the async Clipboard API, falling back to execCommand only where the async API is unavailable; track the copy-note timeout per note via a WeakMap. - CSS convention: theming vars become --item-navigator-* with --x-- aliases and inline defaults; base-font-family from themeStyles replaces the hardcoded font stack. - Wire the share panel into the demo as a menu provider. - 5 Vitest tests: option population, share-URL building, embed snippets, header toggle, and the copy-note flash. Co-Authored-By: Claude Opus 4.8 --- .../ia-item-navigator-story.ts | 16 + .../menus/ia-share-panel.test.ts | 86 ++++ .../ia-item-navigator/menus/ia-share-panel.ts | 368 ++++++++++++++++++ .../ia-item-navigator/menus/share-icons.ts | 108 +++++ 4 files changed, 578 insertions(+) create mode 100644 src/labs/ia-item-navigator/menus/ia-share-panel.test.ts create mode 100644 src/labs/ia-item-navigator/menus/ia-share-panel.ts create mode 100644 src/labs/ia-item-navigator/menus/share-icons.ts diff --git a/src/labs/ia-item-navigator/ia-item-navigator-story.ts b/src/labs/ia-item-navigator/ia-item-navigator-story.ts index ab035f2..a268af0 100644 --- a/src/labs/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/labs/ia-item-navigator/ia-item-navigator-story.ts @@ -4,7 +4,9 @@ import { customElement, state } from 'lit/decorators.js'; import './ia-item-navigator'; import './menus/ia-viewable-files-panel'; import './menus/ia-sort-files-button'; +import './menus/ia-share-panel'; import { viewableFilesIcon } from './menus/ia-viewable-files-panel'; +import { shareIcon } from './menus/ia-share-panel'; import type { MenuProviderInterface } from './interfaces/menu-interfaces'; import type { FileSortOption, ViewableFileInfo } from './menus/models'; import type { @@ -146,6 +148,20 @@ export class IAItemNavigatorStory extends LitElement { > `, }, + { + ...shared, + id: 'share', + label: 'Share', + icon: shareIcon, + component: html` + + `, + }, { ...shared, id: 'about', diff --git a/src/labs/ia-item-navigator/menus/ia-share-panel.test.ts b/src/labs/ia-item-navigator/menus/ia-share-panel.test.ts new file mode 100644 index 0000000..c3996de --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-share-panel.test.ts @@ -0,0 +1,86 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { html } from 'lit'; +import { describe, expect, test } from 'vitest'; + +import { IASharePanel } from './ia-share-panel'; +import './ia-share-panel'; + +describe('IASharePanel', () => { + test('auto-populates the social sharing options', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const links = el.shadowRoot?.querySelectorAll('a.share-option'); + expect(links).to.have.lengthOf(5); + const names = [...(links ?? [])].map((a) => a.textContent?.trim()); + expect(names).to.deep.equal([ + 'Twitter', + 'Facebook', + 'Tumblr', + 'Pinterest', + 'Email', + ]); + }); + + test('builds share URLs from the identifier, host and sub-prefix', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const twitter = el.sharingOptions.find((o) => o.name === 'Twitter'); + expect(twitter?.url).to.contain( + encodeURIComponent('https://example.org/details/my-item/vol1'), + ); + }); + + test('exposes iframe and bbcode embed snippets and a help URL', async () => { + const el = await fixture( + html``, + ); + expect(el.iframeEmbed).to.contain( + 'src="https://example.org/embed/my-item"', + ); + expect(el.bbcodeEmbed).to.contain('[archiveorg my-item'); + expect(el.helpURL).to.contain('identifier=my-item'); + }); + + test('renders a header only when renderHeader is set', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('header')).to.not.exist; + + el.renderHeader = true; + await el.updateComplete; + expect(el.shadowRoot?.querySelector('header h3')?.textContent).to.contain( + 'Share this book', + ); + }); + + test('flashes the "copied" note when an embed block is clicked', async () => { + const el = await fixture( + html``, + ); + await el.updateComplete; + + const code = el.shadowRoot?.querySelector('.code') as HTMLElement; + const note = code.querySelector('small') as HTMLElement; + expect(note.classList.contains('visible')).to.equal(false); + + code.click(); + // copy handler is async (Clipboard API, then note toggle) + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(note.classList.contains('visible')).to.equal(true); + }); +}); diff --git a/src/labs/ia-item-navigator/menus/ia-share-panel.ts b/src/labs/ia-item-navigator/menus/ia-share-panel.ts new file mode 100644 index 0000000..1e489c1 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/ia-share-panel.ts @@ -0,0 +1,368 @@ +import { + css, + html, + LitElement, + nothing, + PropertyValues, + TemplateResult, + type CSSResultGroup, +} from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; +import { + shareIcon, + twitterIcon, + facebookIcon, + tumblrIcon, + pinterestIcon, + emailIcon, + linkIcon, +} from './share-icons'; + +type ShareOption = { + name: string; + icon: TemplateResult | string; + url: string; +}; + +/** Icon for the share menu shortcut / menu entry. */ +export { shareIcon }; + +/** + * The "share this item" side panel: social sharing links (Twitter, Facebook, + * Tumblr, Pinterest, email) plus copyable iframe / bbcode embed snippets. + * Sharing options are derived from the item identifier/metadata and rebuilt + * whenever `sharingOptions` is reset to empty. + */ +@customElement('ia-share-panel') +export class IASharePanel extends LitElement { + @property({ type: String }) baseHost = 'archive.org'; + + @property({ type: String }) creator = ''; + + @property({ type: String }) description = ''; + + @property({ type: Boolean }) embedOptionsVisible = false; + + @property({ type: String }) identifier = ''; + + @property({ type: Array }) sharingOptions: ShareOption[] = []; + + @property({ type: String }) type = ''; + + @property({ type: Boolean }) renderHeader = false; + + @property({ type: String }) fileSubPrefix = ''; + + private copyNoteTimeouts = new WeakMap< + Element, + ReturnType + >(); + + updated(changed: PropertyValues): void { + if (changed.has('sharingOptions') && !this.sharingOptions.length) { + this.loadProviders(); + } + } + + loadProviders(): void { + let shareUrl = `https://${this.baseHost}/details/${this.identifier}`; + if (this.fileSubPrefix) { + shareUrl += `/${this.fileSubPrefix}`; + } + const shareBlurb = [ + this.description, + this.creator, + 'Free Download, Borrow, and Streaming', + 'Internet Archive', + ] + .filter(Boolean) + .join(' : '); + + this.sharingOptions = [ + { + name: 'Twitter', + icon: twitterIcon, + url: `https://twitter.com/intent/tweet?${new URLSearchParams({ + url: shareUrl, + text: shareBlurb, + via: 'internetarchive', + })}`, + }, + { + name: 'Facebook', + icon: facebookIcon, + url: `https://www.facebook.com/sharer/sharer.php?${new URLSearchParams({ + u: shareUrl, + })}`, + }, + { + name: 'Tumblr', + icon: tumblrIcon, + url: `https://www.tumblr.com/widgets/share/tool/preview?${new URLSearchParams( + { + posttype: 'link', + canonicalUrl: shareUrl, + title: shareBlurb, + }, + )}`, + }, + { + name: 'Pinterest', + icon: pinterestIcon, + url: `http://www.pinterest.com/pin/create/button/?${new URLSearchParams( + { + url: shareUrl, + description: shareBlurb, + }, + )}`, + }, + { + name: 'Email', + icon: emailIcon, + url: `mailto:?${new URLSearchParams({ + subject: shareBlurb, + body: shareUrl, + })}`, + }, + ]; + } + + /** + * Copies the embed snippet in the clicked `.code` block, preferring the async + * Clipboard API and falling back to the legacy `execCommand` path for + * browsers that lack it, then briefly flashes the "Copied" note. + */ + private async copyToClipboard(event: MouseEvent): Promise { + const currentTarget = event.currentTarget as HTMLElement; + const textarea = currentTarget.querySelector('textarea'); + const note = currentTarget.querySelector('small'); + if (!textarea || !note) return; + + try { + await navigator.clipboard.writeText(textarea.value); + } catch { + // Fallback for browsers without the async Clipboard API. + textarea.select(); + document.execCommand('copy'); + textarea.blur(); + } + + note.classList.add('visible'); + clearTimeout(this.copyNoteTimeouts.get(note)); + this.copyNoteTimeouts.set( + note, + setTimeout(() => note.classList.remove('visible'), 4000), + ); + } + + get iframeEmbed(): string { + return ``; + } + + get bbcodeEmbed(): string { + return `[archiveorg ${this.identifier} width=560 height=384 frameborder=0 webkitallowfullscreen=true mozallowfullscreen=true]`; + } + + get helpURL(): string { + return `https://${this.baseHost}/help/audio.php?identifier=${this.identifier}`; + } + + get header(): TemplateResult | typeof nothing { + const header = html`

    Share this ${this.type}

    `; + return this.renderHeader ? header : nothing; + } + + render(): TemplateResult { + return html` + ${this.header} +
    + ${this.sharingOptions.map( + (option) => + html``, + )} +
    + +
    +

    Embed

    +
    + + Copied to clipboard +
    +

    + Embed for wordpress.com hosted blogs and archive.org item + <description> tags +

    +
    + + Copied to clipboard +
    +

    + Want more? + Advanced embedding details, examples, and help! +

    +
    +
    +
    + `; + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --item-navigator-text-color--: var( + --item-navigator-text-color, + var(--true-white) + ); + --item-navigator-share-link-color--: var( + --item-navigator-share-link-color, + var(--true-white) + ); + --item-navigator-share-icon-border--: var( + --item-navigator-share-icon-border, + #4b4b4b + ); + --item-navigator-share-icon-bg--: var( + --item-navigator-share-icon-bg, + transparent + ); + --item-navigator-icon-fill-color--: var( + --item-navigator-icon-fill-color, + var(--true-white) + ); + --item-navigator-textarea-color--: var( + --item-navigator-textarea-color, + var(--true-white) + ); + --item-navigator-textarea-bg--: var( + --item-navigator-textarea-bg, + #151515 + ); + + display: block; + height: 100%; + overflow-y: auto; + font-size: 1.4rem; + box-sizing: border-box; + } + + header { + display: flex; + align-items: baseline; + } + + h3 { + padding: 0; + margin: 0 1rem 0 0; + font-size: 1.6rem; + } + + h4 { + font-size: 1.4rem; + } + + :host > div { + padding: 1rem 0; + } + + .share-option { + display: block; + padding: 0.5rem 0; + font-size: 1.6rem; + text-decoration: none; + color: var(--item-navigator-share-link-color--); + cursor: pointer; + transition: background-color 0.2s; + border-radius: 6px; + } + + .share-option:hover { + background-color: rgba(255, 255, 255, 0.05); + } + + .share-option > * { + display: inline-block; + padding: 0.2rem; + margin-right: 1rem; + vertical-align: middle; + border: 1px solid var(--item-navigator-share-icon-border--); + border-radius: 7px; + background: var(--item-navigator-share-icon-bg--); + } + + .share-option .ia-icon { + width: 2rem; + height: 2rem; + } + + .ia-icon .fill-color { + fill: var(--item-navigator-icon-fill-color--); + } + + /* Hide the triangle that appears on details tags */ + summary::marker { + content: ''; + } + + summary::-webkit-details-marker { + display: none; + } + + .embed { + padding-right: 5px; + } + + .embed a { + color: var(--item-navigator-share-link-color--); + } + + .code { + position: relative; + } + + textarea { + display: block; + width: 100%; + height: 120px; + padding: 0.8rem 1rem; + box-sizing: border-box; + resize: none; + cursor: pointer; + font: normal 1.4rem var(--base-font-family); + color: var(--item-navigator-textarea-color--); + background: var(--item-navigator-textarea-bg--); + } + + small { + position: absolute; + bottom: 0; + left: 0; + height: 3rem; + padding: 0.5rem 1rem; + box-sizing: border-box; + font: normal 1.2rem/2rem var(--base-font-family); + color: var(--item-navigator-textarea-bg--); + background: var(--item-navigator-textarea-color--); + opacity: 0; + transition: opacity 300ms linear; + } + + small.visible { + opacity: 1; + } + `, + ]; + } +} diff --git a/src/labs/ia-item-navigator/menus/share-icons.ts b/src/labs/ia-item-navigator/menus/share-icons.ts new file mode 100644 index 0000000..5fa0305 --- /dev/null +++ b/src/labs/ia-item-navigator/menus/share-icons.ts @@ -0,0 +1,108 @@ +import { html, TemplateResult } from 'lit'; + +/** + * Icons inlined from the `@internetarchive/icon-*` packages (v1.4.1) used by + * the share panel. Inlined rather than depended on so the navigator pulls in + * no icon packages; each path carries `.fill-color` so the panel can theme it + * via CSS. Icons are decorative (labelled by adjacent text), so callers mark + * them `aria-hidden`. + */ + +export const shareIcon: TemplateResult = html` + +`; + +export const twitterIcon: TemplateResult = html` + +`; + +export const facebookIcon: TemplateResult = html` + +`; + +export const tumblrIcon: TemplateResult = html` + +`; + +export const pinterestIcon: TemplateResult = html` + +`; + +export const emailIcon: TemplateResult = html` + +`; + +export const linkIcon: TemplateResult = html` + +`; From e2de30d55cbc167de64a5576af2b4b5bce7a38c3 Mon Sep 17 00:00:00 2001 From: Isa HV Date: Mon, 20 Jul 2026 13:16:55 -0700 Subject: [PATCH 03/17] WEBDEV-8458 Promote item-navigator from labs to elements Move src/labs/ia-item-navigator to src/elements/ia-item-navigator now that the migration is complete, treat it as a production-ready component: - Drop the `labs` flag from the story so it lists under Production-Ready and its import/usage snippets use the @internetarchive/elements/ia-item-navigator path (no /labs segment). - Export the navigator and its consumer-facing menus (viewable-files panel, sort-files button, share panel) from src/elements/index.ts. Co-Authored-By: Claude Opus 4.8 --- .../ia-item-navigator/ia-item-navigator-story.ts | 1 - .../ia-item-navigator/ia-item-navigator.test.ts | 0 src/{labs => elements}/ia-item-navigator/ia-item-navigator.ts | 0 .../ia-item-navigator/ia-itemnav-loader.test.ts | 0 src/{labs => elements}/ia-item-navigator/ia-itemnav-loader.ts | 0 .../ia-item-navigator/ia-menu-button.test.ts | 0 src/{labs => elements}/ia-item-navigator/ia-menu-button.ts | 0 .../ia-item-navigator/ia-menu-slider.test.ts | 0 src/{labs => elements}/ia-item-navigator/ia-menu-slider.ts | 0 .../ia-item-navigator/ia-no-theater-available.test.ts | 0 .../ia-item-navigator/ia-no-theater-available.ts | 0 src/{labs => elements}/ia-item-navigator/icons.ts | 0 .../ia-item-navigator/interfaces/custom-theater-interface.ts | 0 .../ia-item-navigator/interfaces/event-interfaces.ts | 0 .../ia-item-navigator/interfaces/menu-interfaces.ts | 0 .../ia-item-navigator/interfaces/service-interfaces.ts | 0 .../ia-item-navigator/menus/ia-share-panel.test.ts | 0 .../ia-item-navigator/menus/ia-share-panel.ts | 0 .../ia-item-navigator/menus/ia-sort-files-button.test.ts | 0 .../ia-item-navigator/menus/ia-sort-files-button.ts | 0 .../ia-item-navigator/menus/ia-viewable-files-panel.test.ts | 0 .../ia-item-navigator/menus/ia-viewable-files-panel.ts | 0 src/{labs => elements}/ia-item-navigator/menus/models.ts | 0 src/{labs => elements}/ia-item-navigator/menus/share-icons.ts | 0 src/elements/index.ts | 4 ++++ 25 files changed, 4 insertions(+), 1 deletion(-) rename src/{labs => elements}/ia-item-navigator/ia-item-navigator-story.ts (99%) rename src/{labs => elements}/ia-item-navigator/ia-item-navigator.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-item-navigator.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-itemnav-loader.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-itemnav-loader.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-menu-button.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-menu-button.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-menu-slider.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-menu-slider.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-no-theater-available.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/ia-no-theater-available.ts (100%) rename src/{labs => elements}/ia-item-navigator/icons.ts (100%) rename src/{labs => elements}/ia-item-navigator/interfaces/custom-theater-interface.ts (100%) rename src/{labs => elements}/ia-item-navigator/interfaces/event-interfaces.ts (100%) rename src/{labs => elements}/ia-item-navigator/interfaces/menu-interfaces.ts (100%) rename src/{labs => elements}/ia-item-navigator/interfaces/service-interfaces.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-share-panel.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-share-panel.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-sort-files-button.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-sort-files-button.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-viewable-files-panel.test.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/ia-viewable-files-panel.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/models.ts (100%) rename src/{labs => elements}/ia-item-navigator/menus/share-icons.ts (100%) diff --git a/src/labs/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts similarity index 99% rename from src/labs/ia-item-navigator/ia-item-navigator-story.ts rename to src/elements/ia-item-navigator/ia-item-navigator-story.ts index a268af0..b71cc27 100644 --- a/src/labs/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -235,7 +235,6 @@ export class IAItemNavigatorStory extends LitElement { elementClassName="IAItemNavigator" .styleInputData=${this.styleInputData} .customExampleUsage=${this.exampleUsage} - labs >
    diff --git a/src/labs/ia-item-navigator/ia-item-navigator.test.ts b/src/elements/ia-item-navigator/ia-item-navigator.test.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-item-navigator.test.ts rename to src/elements/ia-item-navigator/ia-item-navigator.test.ts diff --git a/src/labs/ia-item-navigator/ia-item-navigator.ts b/src/elements/ia-item-navigator/ia-item-navigator.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-item-navigator.ts rename to src/elements/ia-item-navigator/ia-item-navigator.ts diff --git a/src/labs/ia-item-navigator/ia-itemnav-loader.test.ts b/src/elements/ia-item-navigator/ia-itemnav-loader.test.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-itemnav-loader.test.ts rename to src/elements/ia-item-navigator/ia-itemnav-loader.test.ts diff --git a/src/labs/ia-item-navigator/ia-itemnav-loader.ts b/src/elements/ia-item-navigator/ia-itemnav-loader.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-itemnav-loader.ts rename to src/elements/ia-item-navigator/ia-itemnav-loader.ts diff --git a/src/labs/ia-item-navigator/ia-menu-button.test.ts b/src/elements/ia-item-navigator/ia-menu-button.test.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-menu-button.test.ts rename to src/elements/ia-item-navigator/ia-menu-button.test.ts diff --git a/src/labs/ia-item-navigator/ia-menu-button.ts b/src/elements/ia-item-navigator/ia-menu-button.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-menu-button.ts rename to src/elements/ia-item-navigator/ia-menu-button.ts diff --git a/src/labs/ia-item-navigator/ia-menu-slider.test.ts b/src/elements/ia-item-navigator/ia-menu-slider.test.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-menu-slider.test.ts rename to src/elements/ia-item-navigator/ia-menu-slider.test.ts diff --git a/src/labs/ia-item-navigator/ia-menu-slider.ts b/src/elements/ia-item-navigator/ia-menu-slider.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-menu-slider.ts rename to src/elements/ia-item-navigator/ia-menu-slider.ts diff --git a/src/labs/ia-item-navigator/ia-no-theater-available.test.ts b/src/elements/ia-item-navigator/ia-no-theater-available.test.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-no-theater-available.test.ts rename to src/elements/ia-item-navigator/ia-no-theater-available.test.ts diff --git a/src/labs/ia-item-navigator/ia-no-theater-available.ts b/src/elements/ia-item-navigator/ia-no-theater-available.ts similarity index 100% rename from src/labs/ia-item-navigator/ia-no-theater-available.ts rename to src/elements/ia-item-navigator/ia-no-theater-available.ts diff --git a/src/labs/ia-item-navigator/icons.ts b/src/elements/ia-item-navigator/icons.ts similarity index 100% rename from src/labs/ia-item-navigator/icons.ts rename to src/elements/ia-item-navigator/icons.ts diff --git a/src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts b/src/elements/ia-item-navigator/interfaces/custom-theater-interface.ts similarity index 100% rename from src/labs/ia-item-navigator/interfaces/custom-theater-interface.ts rename to src/elements/ia-item-navigator/interfaces/custom-theater-interface.ts diff --git a/src/labs/ia-item-navigator/interfaces/event-interfaces.ts b/src/elements/ia-item-navigator/interfaces/event-interfaces.ts similarity index 100% rename from src/labs/ia-item-navigator/interfaces/event-interfaces.ts rename to src/elements/ia-item-navigator/interfaces/event-interfaces.ts diff --git a/src/labs/ia-item-navigator/interfaces/menu-interfaces.ts b/src/elements/ia-item-navigator/interfaces/menu-interfaces.ts similarity index 100% rename from src/labs/ia-item-navigator/interfaces/menu-interfaces.ts rename to src/elements/ia-item-navigator/interfaces/menu-interfaces.ts diff --git a/src/labs/ia-item-navigator/interfaces/service-interfaces.ts b/src/elements/ia-item-navigator/interfaces/service-interfaces.ts similarity index 100% rename from src/labs/ia-item-navigator/interfaces/service-interfaces.ts rename to src/elements/ia-item-navigator/interfaces/service-interfaces.ts diff --git a/src/labs/ia-item-navigator/menus/ia-share-panel.test.ts b/src/elements/ia-item-navigator/menus/ia-share-panel.test.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-share-panel.test.ts rename to src/elements/ia-item-navigator/menus/ia-share-panel.test.ts diff --git a/src/labs/ia-item-navigator/menus/ia-share-panel.ts b/src/elements/ia-item-navigator/menus/ia-share-panel.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-share-panel.ts rename to src/elements/ia-item-navigator/menus/ia-share-panel.ts diff --git a/src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts b/src/elements/ia-item-navigator/menus/ia-sort-files-button.test.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-sort-files-button.test.ts rename to src/elements/ia-item-navigator/menus/ia-sort-files-button.test.ts diff --git a/src/labs/ia-item-navigator/menus/ia-sort-files-button.ts b/src/elements/ia-item-navigator/menus/ia-sort-files-button.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-sort-files-button.ts rename to src/elements/ia-item-navigator/menus/ia-sort-files-button.ts diff --git a/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.test.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-viewable-files-panel.test.ts rename to src/elements/ia-item-navigator/menus/ia-viewable-files-panel.test.ts diff --git a/src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/ia-viewable-files-panel.ts rename to src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts diff --git a/src/labs/ia-item-navigator/menus/models.ts b/src/elements/ia-item-navigator/menus/models.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/models.ts rename to src/elements/ia-item-navigator/menus/models.ts diff --git a/src/labs/ia-item-navigator/menus/share-icons.ts b/src/elements/ia-item-navigator/menus/share-icons.ts similarity index 100% rename from src/labs/ia-item-navigator/menus/share-icons.ts rename to src/elements/ia-item-navigator/menus/share-icons.ts diff --git a/src/elements/index.ts b/src/elements/index.ts index 4847a5e..f2e1d1b 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -1,3 +1,7 @@ export * from './ia-button/ia-button'; export * from './ia-combo-box/ia-combo-box'; export * from './ia-dropdown-search-bar/ia-dropdown-search-bar'; +export * from './ia-item-navigator/ia-item-navigator'; +export * from './ia-item-navigator/menus/ia-viewable-files-panel'; +export * from './ia-item-navigator/menus/ia-sort-files-button'; +export * from './ia-item-navigator/menus/ia-share-panel'; From 8cb1bd9a3e5df38a03cdf0aac1025c7e258f1c2f Mon Sep 17 00:00:00 2001 From: Isa HV Date: Mon, 20 Jul 2026 13:34:23 -0700 Subject: [PATCH 04/17] WEBDEV-8458 Enrich item-navigator demo with realistic examples Fold the useful examples from the upstream demo into the story: - Realistic multi-file item (the "Master Book of American Folk Song" volumes) for the viewable-files panel, including a deliberately long title (wrapping) and PDF entries (PDF flag), with one active file. - A slotted header bar (brand + item title link) and a styled theater mock in slot="main", so the shell's host-content slots are shown in use. - A populated minimized shortcut rail (menuShortcuts) alongside the drawer. - Header, view-available, and fullscreen toggles; fullscreen renders an in-frame "Exit fullscreen" control so the demo can't trap the viewer. - Size shortcut-rail icons consistently with the toggle button, and give viewableFilesIcon the shared .ia-icon class so it themes/sizes like the rest. Kept self-contained: no metadata-service fetch or external image hotlinks. Co-Authored-By: Claude Opus 4.8 --- .../ia-item-navigator-story.ts | 358 +++++++++++++----- .../ia-item-navigator/ia-item-navigator.ts | 3 +- .../menus/ia-viewable-files-panel.ts | 1 + 3 files changed, 259 insertions(+), 103 deletions(-) diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index b71cc27..da7aca0 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -1,4 +1,4 @@ -import { css, html, LitElement, type CSSResultGroup } from 'lit'; +import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import './ia-item-navigator'; @@ -7,7 +7,10 @@ import './menus/ia-sort-files-button'; import './menus/ia-share-panel'; import { viewableFilesIcon } from './menus/ia-viewable-files-panel'; import { shareIcon } from './menus/ia-share-panel'; -import type { MenuProviderInterface } from './interfaces/menu-interfaces'; +import type { + MenuProviderInterface, + MenuShortcutInterface, +} from './interfaces/menu-interfaces'; import type { FileSortOption, ViewableFileInfo } from './menus/models'; import type { SharedResizeObserverConfig, @@ -42,57 +45,114 @@ class DemoResizeObserver implements SharedResizeObserverInterface { } } -/** A generic list-ish glyph for demo menu entries. */ +/** A generic list-ish glyph for the "About" demo menu entry. */ const demoIcon = html` -
      -
    • Chapter 1 — Introduction
    • -
    • Chapter 2 — Getting Started
    • -
    • Chapter 3 — In Practice
    • -
    - `, - }, { ...shared, id: 'viewable-files', - label: 'Viewable Files', + label: `Viewable Files (${DEMO_FILES.length})`, icon: viewableFilesIcon, actionButton: html` `, component: html` @@ -151,14 +201,15 @@ export class IAItemNavigatorStory extends LitElement { { ...shared, id: 'share', - label: 'Share', + label: 'Share this item', icon: shareIcon, component: html` `, }, @@ -169,14 +220,27 @@ export class IAItemNavigatorStory extends LitElement { icon: demoIcon, component: html`

    - A demonstration of the item navigator shell. Each menu entry above - is a "provider" supplying its own panel body. + The item navigator is a shell: each menu entry here is a "provider" + supplying its own panel body. The theater on the right is slotted in + by the host.

    `, }, ]; } + /** Minimized-rail shortcuts, mirroring the upstream demo. */ + private get menuShortcuts(): MenuShortcutInterface[] { + return [ + { + id: 'viewable-files', + label: 'Viewable Files', + icon: viewableFilesIcon, + }, + { id: 'share', label: 'Share this item', icon: shareIcon }, + ]; + } + private get styleInputData(): StyleInputData { return { settings: [ @@ -237,79 +301,116 @@ export class IAItemNavigatorStory extends LitElement { .customExampleUsage=${this.exampleUsage} >
    -
    +
    -
    Demo item header
    -
    -

    - Your theater (book reader, media player, …) slots in here. -

    -
    + ${this.headerTemplate} ${this.theaterTemplate}
    - - - - - - - - + ${this.toggleRow('Loaded', 'loaded')} + ${this.toggleRow('View available (theater)', 'viewAvailable')} + ${this.toggleRow('Header', 'headerOn')} + ${this.toggleRow('Fullscreen', 'fullscreen')}
    Loaded - - (this.loaded = (e.target as HTMLInputElement).checked)} - /> -
    View available - - (this.viewAvailable = ( - e.target as HTMLInputElement - ).checked)} - /> -

    - Toggle "View available" off to show the no-theater placeholder. - Narrow the demo below 600px wide to see the drawer switch from shift - to overlay mode. + Turn "View available" off to show the no-theater placeholder. Open + "Viewable Files" and use the sort button in its header. Narrow the + demo below 600px to see the drawer switch from shift to overlay.

    The navigator is a shell: project a theater into - slot="main" and drive the drawer with the - menuContents provider array. It never renders a viewer - itself. + slot="main" and an optional bar into + slot="header", then drive the drawer with the + menuContents provider array (and the minimized rail + with menuShortcuts). It never renders a viewer itself.

    `; } + /** A row with a labelled checkbox bound to the given boolean state field. */ + private toggleRow( + label: string, + field: 'loaded' | 'viewAvailable' | 'headerOn' | 'fullscreen', + ) { + return html` + + ${label} + + { + this[field] = (e.target as HTMLInputElement).checked; + }} + /> + + + `; + } + + private get headerTemplate() { + if (!this.headerOn && !this.fullscreen) return nothing; + return html` +
    + Internet Archive + The Master Book of American Folk Song + ${this.fullscreen + ? html`` + : nothing} +
    + `; + } + + private get theaterTemplate() { + return html` +
    +
    +
    +
    +

    The Master Book of American Folk Song

    +

    + Your theater (book reader, media player, image viewer, …) renders + here. +

    +
    +
    +
    + `; + } + private get exampleUsage(): string { return ` @@ -321,25 +422,78 @@ export class IAItemNavigatorStory extends LitElement { static get styles(): CSSResultGroup { return css` .frame-wrapper { - height: 420px; + height: 460px; border: 1px solid #ccc; } .demo-header { + display: flex; + align-items: center; + gap: 12px; background: #1a1a1a; color: #fff; padding: 8px 12px; font-size: 0.9rem; } + .demo-header .brand { + font-weight: 600; + white-space: nowrap; + } + + .demo-header .title { + color: #6cb2ff; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .demo-header .exit-fs { + margin-left: auto; + cursor: pointer; + } + .demo-theater { display: flex; align-items: center; justify-content: center; height: 100%; - color: #fff; - text-align: center; padding: 1rem; + box-sizing: border-box; + } + + .viewer-mock { + display: flex; + max-width: 320px; + width: 100%; + min-height: 220px; + border-radius: 4px; + overflow: hidden; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + background: #2b2b2b; + } + + .viewer-mock .spine { + width: 14px; + background: linear-gradient(90deg, #111, #444); + } + + .viewer-mock .page { + flex: 1; + padding: 1.5rem 1.25rem; + color: #eee; + } + + .viewer-title { + margin: 0 0 0.75rem; + font-weight: 700; + } + + .viewer-note { + margin: 0; + font-size: 0.85rem; + color: #aaa; } .hint { diff --git a/src/elements/ia-item-navigator/ia-item-navigator.ts b/src/elements/ia-item-navigator/ia-item-navigator.ts index f0c799a..b868d97 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.ts @@ -535,7 +535,8 @@ export class IAItemNavigator margin: auto; } - .toggle-menu .ia-icon { + .toggle-menu .ia-icon, + .shortcuts .ia-icon { width: ${iconWidth}; height: ${iconHeight}; } diff --git a/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts index 1576549..a5b67fe 100644 --- a/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts +++ b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts @@ -14,6 +14,7 @@ import type { FileSortOption, ViewableFileInfo } from './models'; /** Icon for the viewable-files menu shortcut / menu entry. */ export const viewableFilesIcon = html` Date: Mon, 20 Jul 2026 13:57:57 -0700 Subject: [PATCH 05/17] WEBDEV-8458 Anchor item-navigator sizing to a 10px base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The components' internal sizing was in rem, calibrated for petabox's 10px root font-size (which the upstream demo set on the document). In the elements demo (default 16px root) everything — icons especially — rendered ~1.6x too large. Make the navigator self-contained instead of depending on the consumer's root: - Set font-size: var(--item-navigator-base-font-size, 10px) on each component's :host and convert internal rem units to em, so sizing resolves against the component's own base. Consumers can override the var to rescale everything. - Normalize buttons with `font: inherit` (and reset the header/social icon wrappers), since form controls don't inherit font-size — otherwise em icons resolved against the browser's default button font-size. - Move menu-button text sizing off the flex container onto the label so the icon box isn't compounded. Verified in the demo: toggle/shortcut icons 24px, menu icon box 42px, labels 16px, close/sort/share icons ~20px — matching the upstream 10px-base scale. Co-Authored-By: Claude Opus 4.8 --- .../ia-item-navigator/ia-item-navigator.ts | 19 ++++++++-- .../ia-item-navigator/ia-itemnav-loader.ts | 3 ++ .../ia-item-navigator/ia-menu-button.ts | 22 +++++++----- .../ia-item-navigator/ia-menu-slider.ts | 22 +++++++----- .../ia-no-theater-available.ts | 10 +++--- .../ia-item-navigator/menus/ia-share-panel.ts | 36 ++++++++++--------- .../menus/ia-sort-files-button.ts | 3 ++ .../menus/ia-viewable-files-panel.ts | 10 +++--- 8 files changed, 82 insertions(+), 43 deletions(-) diff --git a/src/elements/ia-item-navigator/ia-item-navigator.ts b/src/elements/ia-item-navigator/ia-item-navigator.ts index b868d97..dc64b70 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.ts @@ -407,11 +407,11 @@ export class IAItemNavigator ); --item-navigator-icon-width--: var( --item-navigator-icon-width, - 2.4rem + 2.4em ); --item-navigator-icon-height--: var( --item-navigator-icon-height, - 2.4rem + 2.4em ); --item-navigator-icon-stroke-color--: var( --item-navigator-icon-stroke-color, @@ -421,6 +421,15 @@ export class IAItemNavigator --item-navigator-icon-fill-color, var(--true-white) ); + + /* + * The component's internal sizing is expressed in em against this + * base (10px matches petabox's base font size, which the upstream + * demo set on the document root). Anchoring it here makes the + * navigator self-contained — its scale no longer depends on the + * consumer's root font-size. Override to rescale everything. + */ + font-size: var(--item-navigator-base-font-size, 10px); } :host, @@ -486,6 +495,10 @@ export class IAItemNavigator } button { + /* Buttons don't inherit font-size from the UA stylesheet; inherit it + so em-sized icons resolve against the component's base, not the + browser's default button font-size. */ + font: inherit; cursor: pointer; padding: 0; border: 0; @@ -512,7 +525,7 @@ export class IAItemNavigator } nav .minimized button { - margin-bottom: 0.2rem; + margin-bottom: 0.2em; margin: auto; display: inline-flex; vertical-align: middle; diff --git a/src/elements/ia-item-navigator/ia-itemnav-loader.ts b/src/elements/ia-item-navigator/ia-itemnav-loader.ts index 8c6df83..fe7df30 100644 --- a/src/elements/ia-item-navigator/ia-itemnav-loader.ts +++ b/src/elements/ia-item-navigator/ia-itemnav-loader.ts @@ -77,6 +77,9 @@ export class IAItemNavLoader extends LitElement { --item-navigator-text-color, var(--true-white) ); + + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } .place-holder { diff --git a/src/elements/ia-item-navigator/ia-menu-button.ts b/src/elements/ia-item-navigator/ia-menu-button.ts index c74bc89..638ded7 100644 --- a/src/elements/ia-item-navigator/ia-menu-button.ts +++ b/src/elements/ia-item-navigator/ia-menu-button.ts @@ -121,6 +121,9 @@ export class IAMenuButton extends LitElement { --item-navigator-icon-active-fill, var(--true-white) ); + + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } a { @@ -131,13 +134,15 @@ export class IAMenuButton extends LitElement { button.menu-item { -webkit-appearance: none; appearance: none; + /* Inherit font-size so the em-sized icon/label resolve against the + component base rather than the UA default button font-size. */ + font: inherit; } .menu-item { display: inline-flex; width: 100%; padding: 0; - font-size: 1.6rem; text-align: left; background: transparent; align-items: center; @@ -154,28 +159,29 @@ export class IAMenuButton extends LitElement { .label { display: var(--item-navigator-menu-button-label-display--); padding: 0; + font-size: 1.6em; font-weight: 400; color: var(--item-navigator-text-color--); text-align: left; vertical-align: middle; - margin-left: 1rem; + margin-left: 1em; } .menu-details { color: var(--item-navigator-text-color--); display: inline-block; - margin-left: 0.5rem; + margin-left: 0.5em; font-style: italic; - font-size: 1.5rem; + font-size: 1.5em; } .menu-item > .icon { position: relative; display: inline-flex; z-index: 2; - min-width: 4.2rem; - max-width: 4.2rem; - height: 4.2rem; + min-width: 4.2em; + max-width: 4.2em; + height: 4.2em; vertical-align: middle; align-items: center; justify-content: center; @@ -188,7 +194,7 @@ export class IAMenuButton extends LitElement { .menu-item[aria-expanded='true'] .icon { background-color: var(--item-navigator-active-button-bg--); - border-radius: 1rem 0 0 1rem; + border-radius: 1em 0 0 1em; } .icon .fill-color { diff --git a/src/elements/ia-item-navigator/ia-menu-slider.ts b/src/elements/ia-item-navigator/ia-menu-slider.ts index 3ea502d..d333cc5 100644 --- a/src/elements/ia-item-navigator/ia-menu-slider.ts +++ b/src/elements/ia-item-navigator/ia-menu-slider.ts @@ -240,16 +240,19 @@ export class IAMenuSlider extends LitElement { ); --item-navigator-header-icon-width--: var( --item-navigator-header-icon-width, - 2.4rem + 2em ); --item-navigator-header-icon-height--: var( --item-navigator-header-icon-height, - 2.4rem + 2em ); --item-navigator-icon-fill-color--: var( --item-navigator-icon-fill-color, var(--true-white) ); + + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } .main { @@ -268,9 +271,9 @@ export class IAMenuSlider extends LitElement { left: 0; bottom: 0; width: ${sliderWidth}; - padding: 0.5rem 0.5rem 0 0; + padding: 0.5em 0.5em 0 0; box-sizing: border-box; - font-size: 1.4rem; + font-size: 1.4em; color: var(--item-navigator-text-color--); background: var(--item-navigator-menu-slider-bg--); transform: translateX(calc(${sliderWidth} * -1)); @@ -281,7 +284,7 @@ export class IAMenuSlider extends LitElement { } header { - margin: 0 0 0.5rem 0; + margin: 0 0 0.5em 0; } header * { @@ -315,6 +318,9 @@ export class IAMenuSlider extends LitElement { } button.close { + /* Reset to the base so the header icon (em) doesn't compound + against .menu's enlarged font-size. */ + font-size: var(--item-navigator-base-font-size, 10px); min-width: 38px; min-height: 38px; display: flex; @@ -343,9 +349,9 @@ export class IAMenuSlider extends LitElement { transform: translateX(calc(${sliderWidth} * -1)); transition: transform ${transitionTiming} ease-out; background: var(--item-navigator-active-button-bg--); - border-right: 0.2rem solid; + border-right: 0.2em solid; border-color: var(--item-navigator-subpanel-border-color--); - padding: 0.5rem 0 0 0.5rem; + padding: 0.5em 0 0 0.5em; display: flex; flex-direction: column; } @@ -362,7 +368,7 @@ export class IAMenuSlider extends LitElement { } .menu-list li { - margin-bottom: 0.2rem; + margin-bottom: 0.2em; } .content > section { diff --git a/src/elements/ia-item-navigator/ia-no-theater-available.ts b/src/elements/ia-item-navigator/ia-no-theater-available.ts index 2bcd460..e449c5e 100644 --- a/src/elements/ia-item-navigator/ia-no-theater-available.ts +++ b/src/elements/ia-item-navigator/ia-no-theater-available.ts @@ -62,6 +62,8 @@ export class IANoTheaterAvailable extends LitElement { ); color: var(--item-navigator-text-color--); text-align: center; + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } section { @@ -71,7 +73,7 @@ export class IANoTheaterAvailable extends LitElement { } p { - font-size: 1.4rem; + font-size: 1.4em; } a { @@ -80,13 +82,13 @@ export class IANoTheaterAvailable extends LitElement { min-height: 35px; cursor: pointer; line-height: normal; - border-radius: 0.4rem; + border-radius: 0.4em; text-align: center; vertical-align: middle; - font-size: 1.4rem; + font-size: 1.4em; font-family: var(--base-font-family); display: inline-block; - padding: 0.85rem 1.2rem; + padding: 0.85em 1.2em; border: 1px solid var(--lightest-gray); white-space: nowrap; appearance: auto; diff --git a/src/elements/ia-item-navigator/menus/ia-share-panel.ts b/src/elements/ia-item-navigator/menus/ia-share-panel.ts index 1e489c1..7f7a318 100644 --- a/src/elements/ia-item-navigator/menus/ia-share-panel.ts +++ b/src/elements/ia-item-navigator/menus/ia-share-panel.ts @@ -254,7 +254,8 @@ export class IASharePanel extends LitElement { display: block; height: 100%; overflow-y: auto; - font-size: 1.4rem; + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); box-sizing: border-box; } @@ -265,22 +266,22 @@ export class IASharePanel extends LitElement { h3 { padding: 0; - margin: 0 1rem 0 0; - font-size: 1.6rem; + margin: 0 1em 0 0; + font-size: 1.6em; } h4 { - font-size: 1.4rem; + font-size: 1.4em; } :host > div { - padding: 1rem 0; + padding: 1em 0; } .share-option { display: block; - padding: 0.5rem 0; - font-size: 1.6rem; + padding: 0.5em 0; + font-size: 1.6em; text-decoration: none; color: var(--item-navigator-share-link-color--); cursor: pointer; @@ -294,8 +295,8 @@ export class IASharePanel extends LitElement { .share-option > * { display: inline-block; - padding: 0.2rem; - margin-right: 1rem; + padding: 0.2em; + margin-right: 1em; vertical-align: middle; border: 1px solid var(--item-navigator-share-icon-border--); border-radius: 7px; @@ -303,8 +304,11 @@ export class IASharePanel extends LitElement { } .share-option .ia-icon { - width: 2rem; - height: 2rem; + /* Reset to the base so the icon (em) doesn't compound against the + share-option's enlarged font-size. */ + font-size: var(--item-navigator-base-font-size, 10px); + width: 2em; + height: 2em; } .ia-icon .fill-color { @@ -336,11 +340,11 @@ export class IASharePanel extends LitElement { display: block; width: 100%; height: 120px; - padding: 0.8rem 1rem; + padding: 0.8em 1em; box-sizing: border-box; resize: none; cursor: pointer; - font: normal 1.4rem var(--base-font-family); + font: normal 1.4em var(--base-font-family); color: var(--item-navigator-textarea-color--); background: var(--item-navigator-textarea-bg--); } @@ -349,10 +353,10 @@ export class IASharePanel extends LitElement { position: absolute; bottom: 0; left: 0; - height: 3rem; - padding: 0.5rem 1rem; + height: 3em; + padding: 0.5em 1em; box-sizing: border-box; - font: normal 1.2rem/2rem var(--base-font-family); + font: normal 1.2em/2em var(--base-font-family); color: var(--item-navigator-textarea-bg--); background: var(--item-navigator-textarea-color--); opacity: 0; diff --git a/src/elements/ia-item-navigator/menus/ia-sort-files-button.ts b/src/elements/ia-item-navigator/menus/ia-sort-files-button.ts index 6b3c4ef..a66b277 100644 --- a/src/elements/ia-item-navigator/menus/ia-sort-files-button.ts +++ b/src/elements/ia-item-navigator/menus/ia-sort-files-button.ts @@ -169,6 +169,9 @@ export class IASortFilesButton extends LitElement { --item-navigator-icon-fill-color, var(--true-white) ); + + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } button.sort-by { diff --git a/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts index a5b67fe..04d5d68 100644 --- a/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts +++ b/src/elements/ia-item-navigator/menus/ia-viewable-files-panel.ts @@ -142,8 +142,10 @@ export class IAViewableFilesPanel extends LitElement { box-sizing: border-box; color: var(--item-navigator-text-color--); margin-top: 14px; - margin-bottom: 2rem; + margin-bottom: 2em; --active-border-width--: 2px; + /* 10px base (petabox scale); internal sizing is em against it. */ + font-size: var(--item-navigator-base-font-size, 10px); } a { @@ -154,7 +156,7 @@ export class IAViewableFilesPanel extends LitElement { ul { padding: 0; list-style: none; - margin: var(--active-border-width--) 0.5rem 1rem 0; + margin: var(--active-border-width--) 0.5em 1em 0; } ul > li:first-child .separator { @@ -168,7 +170,7 @@ export class IAViewableFilesPanel extends LitElement { li .content { border: var(--active-border-width--) solid transparent; - padding: 0.2rem 0 0.4rem 0.2rem; + padding: 0.2em 0 0.4em 0.2em; } li .content.active { @@ -202,7 +204,7 @@ export class IAViewableFilesPanel extends LitElement { border-radius: 20px; display: inline-block; margin-left: 5px; - font-size: 0.9rem; + font-size: 0.9em; } .pdf-label .sr-only { From cd3b3732a52bffee3b0d2ebb534145f285aa5dff Mon Sep 17 00:00:00 2001 From: Isa HV Date: Mon, 20 Jul 2026 14:14:12 -0700 Subject: [PATCH 06/17] WEBDEV-8458 Match drawer menu icons to the shortcut-rail size The provider icons inside the open drawer weren't explicitly sized, so they filled the 42px icon box instead of matching the 24px minimized-rail shortcuts. Size the glyph within the menu-button icon box to --item-navigator-icon-width-- (the same var the rail uses) and drop the hardcoded 100% inline size on the demo's placeholder icon so CSS controls it. Drawer icons now render 24px, in line with the shortcuts. Co-Authored-By: Claude Opus 4.8 --- .../ia-item-navigator/ia-item-navigator-story.ts | 7 +------ src/elements/ia-item-navigator/ia-menu-button.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index da7aca0..c7f754b 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -47,12 +47,7 @@ class DemoResizeObserver implements SharedResizeObserverInterface { /** A generic list-ish glyph for the "About" demo menu entry. */ const demoIcon = html` -
    +
    ${this.shortcuts} ${this.menuToggleButton}
    - `; } /** - * Assigns a random color to every color input and applies the result, so a - * single click reveals any part of the component that fails to pick up its - * color knobs. Non-color inputs (sizes, timings) are left alone so the - * layout stays readable. + * Recolors the component so a single click reveals any part of it that fails + * to pick up its color knobs — anything still wearing its old color stands + * out against the rest. + * + * With palettes, this swaps in one coordinated theme at a time: the pairings + * stay legible, and a whole-component color shift makes a stray element + * obvious. Without them, each color is randomized on its own. Non-color + * inputs (sizes, timings) are left alone either way so the layout holds + * still. */ private randomizeColors(): void { - this.styleInputs?.forEach((input) => { - if (input.type !== 'color') return; - input.value = randomHexColor(); - }); + const palette = this.nextPalette(); + + if (palette) { + this.appliedPaletteName = palette.name; + this.styleInputs?.forEach((input) => { + const value = palette.values[input.dataset.variable ?? '']; + if (value) input.value = value; + }); + } else { + this.styleInputs?.forEach((input) => { + if (input.type !== 'color') return; + input.value = randomHexColor(); + }); + } this.applyStyles(); } + /** + * Picks a palette other than the one already showing, so every click + * visibly changes something. + */ + private nextPalette(): StylePalette | undefined { + const palettes = this.styleInputData?.palettes ?? []; + if (!palettes.length) return undefined; + + const candidates = + palettes.length > 1 + ? palettes.filter((p) => p.name !== this.appliedPaletteName) + : palettes; + return candidates[Math.floor(Math.random() * candidates.length)]; + } + + /** Whether recoloring would do anything for this story. */ + private get canRandomize(): boolean { + return ( + !!this.styleInputData?.palettes?.length || + (this.styleInputData?.settings ?? []).some((s) => s.inputType === 'color') + ); + } + /** * Restores every input to its default and clears the applied styles, so the * demo component falls back to its own default styling. */ private resetStyles(): void { + this.appliedPaletteName = undefined; const defaults = new Map( (this.styleInputData?.settings ?? []).map((s) => [s.cssVariable, s]), ); + this.rangeReadouts = {}; this.styleInputs?.forEach((input) => { const setting = defaults.get(input.dataset.variable ?? ''); if (!setting) return; input.value = String(setting.defaultValue); - const output = this.renderRoot.querySelector( - `output[for="${CSS.escape(input.id)}"]`, - ); - if (output) { - output.textContent = `${setting.defaultValue}${setting.unit ?? ''}`; - } }); this.dispatchEvent( @@ -133,7 +203,7 @@ export class StoryStylesSettings extends LitElement { /> ${input.inputType === 'range' ? html`${input.defaultValue}${input.unit ?? ''}${this.readoutFor(input)}` : nothing} ` directly: that element holds Lit-rendered content, and + * overwriting its text would eject Lit's markers and break every later + * re-render of the panel. */ private updateRangeReadout(e: Event): void { const input = e.currentTarget as HTMLInputElement; - const output = this.renderRoot.querySelector( - `output[for="${CSS.escape(input.id)}"]`, - ); - if (!output) return; + const variable = input.dataset.variable; + if (!variable) return; const unit = input.dataset.unit ?? ''; - output.textContent = `${input.value}${unit}`; + this.rangeReadouts = { + ...this.rangeReadouts, + [variable]: `${input.value}${unit}`, + }; + } + + /** The text to show beside a range slider — live value, else its default. */ + private readoutFor(input: StyleInputSettings): string { + return ( + this.rangeReadouts[input.cssVariable] ?? + `${input.defaultValue}${input.unit ?? ''}` + ); } /** @@ -207,6 +291,13 @@ export class StoryStylesSettings extends LitElement { input[type='range'] { margin: 5px; } + + /* Names the theme the randomize control just applied. */ + .applied-palette { + margin-left: 0.75em; + font-size: 0.78rem; + color: #595959; + } `, ]; } diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index 26dddb3..01000d4 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -18,7 +18,10 @@ import type { SharedResizeObserverConfig, SharedResizeObserverInterface, } from './interfaces/service-interfaces'; -import type { StyleInputData } from '@demo/story-components/story-styles-settings'; +import type { + StyleInputData, + StylePalette, +} from '@demo/story-components/story-styles-settings'; import '@demo/story-template'; /** @@ -50,6 +53,99 @@ class DemoResizeObserver implements SharedResizeObserverInterface { /** A generic list-ish glyph for the "About" demo menu entry. */ const demoIcon = maskedIcon(listIconUrl); +/** + * Alternate themes for the demo's randomize control. + * + * Each one recolors the whole navigator at once, which makes any element that + * fails to follow its color knob obvious — it keeps the old color while + * everything around it changes. They are supplied as coordinated sets rather + * than random colors so every pairing the component actually renders stays + * legible: text on the drawer, the open panel and the embed field; icons over + * the drawer and over the theater; active vs inactive icons; borders and the + * active-file accent. All clear WCAG AA — 4.5:1 for text, 3:1 for icons, + * borders and other non-text — with text landing at AAA throughout. + * + * The component's own defaults are deliberately absent: Revert already + * restores those. + */ +const DEMO_PALETTES: StylePalette[] = [ + { + name: 'Midnight', + values: { + '--item-navigator-theater-bg-color': '#0d1b2a', + '--item-navigator-share-embed-bg': '#12233a', + '--item-navigator-menu-slider-bg': '#1b263b', + '--item-navigator-active-button-bg': '#2c3e50', + '--item-navigator-text-color': '#e0e6ed', + '--item-navigator-icon-color': '#e0e6ed', + '--item-navigator-icon-active-color': '#7fd8ff', + '--item-navigator-icon-inactive-color': '#9fb3c8', + '--item-navigator-border-color': '#5c7799', + '--item-navigator-active-file-border-color': '#4cc9f0', + }, + }, + { + name: 'Forest', + values: { + '--item-navigator-theater-bg-color': '#08160c', + '--item-navigator-share-embed-bg': '#0e2413', + '--item-navigator-menu-slider-bg': '#14301a', + '--item-navigator-active-button-bg': '#1f4a29', + '--item-navigator-text-color': '#e8f5e9', + '--item-navigator-icon-color': '#e8f5e9', + '--item-navigator-icon-active-color': '#7bd88f', + '--item-navigator-icon-inactive-color': '#9dbca4', + '--item-navigator-border-color': '#4d8259', + '--item-navigator-active-file-border-color': '#7bd88f', + }, + }, + { + name: 'Plum', + values: { + '--item-navigator-theater-bg-color': '#150c1a', + '--item-navigator-share-embed-bg': '#1f1226', + '--item-navigator-menu-slider-bg': '#2b1733', + '--item-navigator-active-button-bg': '#3d2147', + '--item-navigator-text-color': '#f3e8f7', + '--item-navigator-icon-color': '#f3e8f7', + '--item-navigator-icon-active-color': '#d9a6ff', + '--item-navigator-icon-inactive-color': '#b39ec0', + '--item-navigator-border-color': '#8a6398', + '--item-navigator-active-file-border-color': '#d09bff', + }, + }, + { + name: 'Solarized', + values: { + '--item-navigator-theater-bg-color': '#00212b', + '--item-navigator-share-embed-bg': '#002b36', + '--item-navigator-menu-slider-bg': '#073642', + '--item-navigator-active-button-bg': '#0b4553', + '--item-navigator-text-color': '#eee8d5', + '--item-navigator-icon-color': '#eee8d5', + '--item-navigator-icon-active-color': '#5fd3c8', + '--item-navigator-icon-inactive-color': '#93a1a1', + '--item-navigator-border-color': '#4d8fa1', + '--item-navigator-active-file-border-color': '#5fd3c8', + }, + }, + { + name: 'High Contrast', + values: { + '--item-navigator-theater-bg-color': '#000000', + '--item-navigator-share-embed-bg': '#000000', + '--item-navigator-menu-slider-bg': '#000000', + '--item-navigator-active-button-bg': '#1a1a1a', + '--item-navigator-text-color': '#ffffff', + '--item-navigator-icon-color': '#ffffff', + '--item-navigator-icon-active-color': '#ffff00', + '--item-navigator-icon-inactive-color': '#c0c0c0', + '--item-navigator-border-color': '#ffffff', + '--item-navigator-active-file-border-color': '#ffff00', + }, + }, +]; + /** archive.org bases for embeddable item viewers and direct file downloads. */ const EMBED_BASE = 'https://archive.org/embed'; const DOWNLOAD_BASE = 'https://archive.org/download'; @@ -372,6 +468,7 @@ export class IAItemNavigatorStory extends LitElement { inputType: 'color', }, ], + palettes: DEMO_PALETTES, }; } From 04efe013bf3490e2026af3485c2ade42a25388a9 Mon Sep 17 00:00:00 2001 From: Isa HV Date: Wed, 5 Aug 2026 19:06:27 -0400 Subject: [PATCH 14/17] WEBDEV-8458 Keep the demo additions to the item-navigator story The style-variable labels, revert control and randomizer went into the shared settings panel, so they appeared on every component's demo even though this work only concerns the item navigator. Stories now opt in: palettes bring the randomizer, revertable brings revert, and showCssVariables shows each control's custom property. Only the item navigator opts in, so the other six demos render exactly as before. Randomizing without palettes assigned each color on its own, which could pair dark text with a dark panel; with the control now limited to stories that define themes, that path is gone. Co-Authored-By: Claude Opus 4.8 --- .../story-styles-settings.test.ts | 82 +++++++------------ .../story-components/story-styles-settings.ts | 76 ++++++++--------- .../ia-item-navigator-story.ts | 2 + 3 files changed, 67 insertions(+), 93 deletions(-) diff --git a/demo/story-components/story-styles-settings.test.ts b/demo/story-components/story-styles-settings.test.ts index cae6910..27aa655 100644 --- a/demo/story-components/story-styles-settings.test.ts +++ b/demo/story-components/story-styles-settings.test.ts @@ -191,7 +191,7 @@ describe('StoryStylesSettings', () => { expect(input.hasAttribute('step')).to.be.false; }); - test('without palettes, randomizing recolors each color input independently', async () => { + test('leaves the panel untouched when a story opts into nothing', async () => { const el = await makeSettings({ settings: [ { @@ -200,63 +200,30 @@ describe('StoryStylesSettings', () => { defaultValue: '#ffffff', inputType: 'color', }, - { - label: 'Paper', - cssVariable: '--paper', - defaultValue: '#000000', - inputType: 'color', - }, - { label: 'Width', cssVariable: '--width', defaultValue: '10px' }, ], }); - const applied = new Promise((resolve) => { - el.addEventListener('stylesApplied', (e) => resolve(e as CustomEvent), { - once: true, - }); - }); - - randomizeButton(el).click(); - - // Both colors moved off their defaults... - expect(getInput(el, 'ink').value).to.not.equal('#ffffff'); - expect(getInput(el, 'paper').value).to.not.equal('#000000'); - // ...and each is a usable hex color. - expect(getInput(el, 'ink').value).to.match(/^#[0-9a-f]{6}$/); - // The non-color input is deliberately untouched. - expect(getInput(el, 'width').value).to.equal('10px'); - - // The new colors are applied, not just staged in the inputs. - const detail = (await applied).detail.styles as string; - expect(detail).to.contain(`--ink: ${getInput(el, 'ink').value}`); - expect(detail).to.contain('--width: 10px'); + // Stories that ask for no extras must render exactly as before, so + // adding these controls for one component does not alter every demo. + const buttons = [ + ...(el.shadowRoot?.querySelectorAll('button') ?? []), + ].map((b) => b.textContent?.trim()); + expect(buttons).to.deep.equal(['Apply']); + expect(el.shadowRoot?.querySelector('.style-var')).to.not.exist; + expect(el.shadowRoot?.querySelector('.applied-palette')).to.not.exist; }); - test('reverting after randomizing restores the defaults', async () => { - const el = await makeSettings({ + test('shows the CSS variable only when the story asks for it', async () => { + const withVars = await makeSettings({ + showCssVariables: true, settings: [ - { - label: 'Ink', - cssVariable: '--ink', - defaultValue: '#ffffff', - inputType: 'color', - }, + { label: 'Ink', cssVariable: '--ink', defaultValue: '#ffffff' }, ], }); - randomizeButton(el).click(); - expect(getInput(el, 'ink').value).to.not.equal('#ffffff'); - - const reverted = new Promise((resolve) => { - el.addEventListener('stylesApplied', (e) => resolve(e as CustomEvent), { - once: true, - }); - }); - revertButton(el).click(); - - expect(getInput(el, 'ink').value).to.equal('#ffffff'); - // An empty payload clears overrides, falling back to component defaults. - expect((await reverted).detail.styles).to.equal(''); + expect( + withVars.shadowRoot?.querySelector('.style-var')?.textContent, + ).to.contain('--ink'); }); test('input without inputType defaults to type=text', async () => { @@ -288,6 +255,7 @@ describe('StoryStylesSettings', () => { ]; const paletteData: StyleInputData = { + revertable: true, settings: [ { label: 'Ink', @@ -404,16 +372,24 @@ describe('StoryStylesSettings', () => { expect(getInput(el, 'ink').value).to.equal('#ffffff'); }); - test('hides the randomize control when there is nothing to recolor', async () => { - const el = await makeSettings({ + test('offers randomize only alongside palettes, and revert only on request', async () => { + const revertOnly = await makeSettings({ + revertable: true, settings: [ - { label: 'Width', cssVariable: '--width', defaultValue: '10px' }, + { + label: 'Ink', + cssVariable: '--ink', + defaultValue: '#ffffff', + inputType: 'color', + }, ], }); const buttons = [ - ...(el.shadowRoot?.querySelectorAll('button') ?? []), + ...(revertOnly.shadowRoot?.querySelectorAll('button') ?? []), ].map((b) => b.textContent?.trim()); + // Color inputs alone are not enough — without themes there is nothing + // coherent to swap in. expect(buttons.some((b) => b?.includes('Randomize'))).to.be.false; expect(buttons.some((b) => b?.includes('Revert'))).to.be.true; }); diff --git a/demo/story-components/story-styles-settings.ts b/demo/story-components/story-styles-settings.ts index bb74c6f..c35f3f1 100644 --- a/demo/story-components/story-styles-settings.ts +++ b/demo/story-components/story-styles-settings.ts @@ -40,15 +40,20 @@ export type StylePalette = { export type StyleInputData = { settings: StyleInputSettings[]; + + /** + * Alternate themes. Supplying them adds a control that swaps between them, + * which is a quick way to check that every part of a component follows its + * color knobs. + */ palettes?: StylePalette[]; -}; -/** A random `#rrggbb` color, used when a story supplies no palettes. */ -function randomHexColor(): string { - return `#${Math.floor(Math.random() * 0xffffff) - .toString(16) - .padStart(6, '0')}`; -} + /** Adds a control that restores this story's default styling. */ + revertable?: boolean; + + /** Shows the CSS custom property each control sets, beside it. */ + showCssVariables?: boolean; +}; /** * A template for displaying the style options. @@ -83,12 +88,15 @@ export class StoryStylesSettings extends LitElement { ${when( - this.canRandomize, + this.styleInputData.palettes?.length, () => html` `, )} - + ${when( + this.styleInputData.revertable, + () => html``, + )} ${when( this.appliedPaletteName, () => @@ -101,31 +109,23 @@ export class StoryStylesSettings extends LitElement { } /** - * Recolors the component so a single click reveals any part of it that fails - * to pick up its color knobs — anything still wearing its old color stands - * out against the rest. + * Swaps in one of the story's themes, so a single click reveals any part of + * the component that fails to pick up its color knobs — anything still + * wearing its old color stands out against the rest. * - * With palettes, this swaps in one coordinated theme at a time: the pairings - * stay legible, and a whole-component color shift makes a stray element - * obvious. Without them, each color is randomized on its own. Non-color - * inputs (sizes, timings) are left alone either way so the layout holds - * still. + * A whole theme is applied at once rather than a color at a time, so the + * foreground/background pairings stay legible. Values the theme does not + * name (sizes, timings) are left alone so the layout holds still. */ private randomizeColors(): void { const palette = this.nextPalette(); + if (!palette) return; - if (palette) { - this.appliedPaletteName = palette.name; - this.styleInputs?.forEach((input) => { - const value = palette.values[input.dataset.variable ?? '']; - if (value) input.value = value; - }); - } else { - this.styleInputs?.forEach((input) => { - if (input.type !== 'color') return; - input.value = randomHexColor(); - }); - } + this.appliedPaletteName = palette.name; + this.styleInputs?.forEach((input) => { + const value = palette.values[input.dataset.variable ?? '']; + if (value) input.value = value; + }); this.applyStyles(); } @@ -145,14 +145,6 @@ export class StoryStylesSettings extends LitElement { return candidates[Math.floor(Math.random() * candidates.length)]; } - /** Whether recoloring would do anything for this story. */ - private get canRandomize(): boolean { - return ( - !!this.styleInputData?.palettes?.length || - (this.styleInputData?.settings ?? []).some((s) => s.inputType === 'color') - ); - } - /** * Restores every input to its default and clears the applied styles, so the * demo component falls back to its own default styling. @@ -206,9 +198,13 @@ export class StoryStylesSettings extends LitElement { >${this.readoutFor(input)}` : nothing} - ${input.cssVariable} + ${when( + this.styleInputData?.showCssVariables, + () => + html`${input.cssVariable}`, + )} `; diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index 01000d4..531cce2 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -469,6 +469,8 @@ export class IAItemNavigatorStory extends LitElement { }, ], palettes: DEMO_PALETTES, + revertable: true, + showCssVariables: true, }; } From dceca7a2b776c7ae4f6c068fd5c2c9520be71d4e Mon Sep 17 00:00:00 2001 From: Isa HV Date: Wed, 5 Aug 2026 19:42:24 -0400 Subject: [PATCH 15/17] WEBDEV-8458 Namespace the item navigator's elements Custom elements share one global registry, and names like ia-menu-slider or ia-share-panel are generic enough that anything on the page might want them. Three were worse than generic: ia-menu-slider, ia-itemnav-loader and ia-no-theater-available are registered verbatim by @internetarchive/ia-item-navigator, which bookreader and offshoot still load. Since the barrel re-exports this component, importing anything from elements would have registered those names a second time and thrown at module evaluation. The navigator's parts now carry its prefix, with files and classes following the tags. The loader becomes ia-itemnav-loading-view rather than ia-itemnav-loader, since the prefixed name is the one upstream already owns. The navigator itself keeps its name. Each element now declares its tag in HTMLElementTagNameMap, so querySelector is typed and a mistyped tag in a template fails the build instead of silently rendering an inert element. A test asserts the new names are registered and the old ones are left free, so a half-finished rename cannot pass quietly. Doing this before release costs consumers nothing: they reference the upstream tags today and rewrite those either way when they migrate. Co-Authored-By: Claude Opus 4.8 --- README.md | 20 +++++++ .../ia-item-navigator/element-names.test.ts | 58 +++++++++++++++++++ .../ia-item-navigator-story.ts | 22 +++---- .../ia-item-navigator.test.ts | 24 ++++---- .../ia-item-navigator/ia-item-navigator.ts | 34 ++++++----- ...est.ts => ia-itemnav-loading-view.test.ts} | 20 +++---- ...v-loader.ts => ia-itemnav-loading-view.ts} | 10 +++- ...test.ts => ia-itemnav-menu-button.test.ts} | 35 ++++++----- ...nu-button.ts => ia-itemnav-menu-button.ts} | 10 +++- ...test.ts => ia-itemnav-menu-slider.test.ts} | 20 +++---- ...nu-slider.ts => ia-itemnav-menu-slider.ts} | 18 ++++-- ...> ia-itemnav-no-theater-available.test.ts} | 26 ++++----- ....ts => ia-itemnav-no-theater-available.ts} | 10 +++- ...test.ts => ia-itemnav-share-panel.test.ts} | 52 ++++++++++------- ...are-panel.ts => ia-itemnav-share-panel.ts} | 10 +++- ...s => ia-itemnav-sort-files-button.test.ts} | 46 +++++++-------- ...ton.ts => ia-itemnav-sort-files-button.ts} | 10 +++- ...> ia-itemnav-viewable-files-panel.test.ts} | 50 ++++++++-------- ....ts => ia-itemnav-viewable-files-panel.ts} | 12 +++- src/elements/index.ts | 6 +- 20 files changed, 318 insertions(+), 175 deletions(-) create mode 100644 src/elements/ia-item-navigator/element-names.test.ts rename src/elements/ia-item-navigator/{ia-itemnav-loader.test.ts => ia-itemnav-loading-view.test.ts} (59%) rename src/elements/ia-item-navigator/{ia-itemnav-loader.ts => ia-itemnav-loading-view.ts} (95%) rename src/elements/ia-item-navigator/{ia-menu-button.test.ts => ia-itemnav-menu-button.test.ts} (73%) rename src/elements/ia-item-navigator/{ia-menu-button.ts => ia-itemnav-menu-button.ts} (96%) rename src/elements/ia-item-navigator/{ia-menu-slider.test.ts => ia-itemnav-menu-slider.test.ts} (94%) rename src/elements/ia-item-navigator/{ia-menu-slider.ts => ia-itemnav-menu-slider.ts} (96%) rename src/elements/ia-item-navigator/{ia-no-theater-available.test.ts => ia-itemnav-no-theater-available.test.ts} (64%) rename src/elements/ia-item-navigator/{ia-no-theater-available.ts => ia-itemnav-no-theater-available.ts} (91%) rename src/elements/ia-item-navigator/menus/{ia-share-panel.test.ts => ia-itemnav-share-panel.test.ts} (76%) rename src/elements/ia-item-navigator/menus/{ia-share-panel.ts => ia-itemnav-share-panel.ts} (97%) rename src/elements/ia-item-navigator/menus/{ia-sort-files-button.test.ts => ia-itemnav-sort-files-button.test.ts} (76%) rename src/elements/ia-item-navigator/menus/{ia-sort-files-button.ts => ia-itemnav-sort-files-button.ts} (94%) rename src/elements/ia-item-navigator/menus/{ia-viewable-files-panel.test.ts => ia-itemnav-viewable-files-panel.test.ts} (74%) rename src/elements/ia-item-navigator/menus/{ia-viewable-files-panel.ts => ia-itemnav-viewable-files-panel.ts} (94%) diff --git a/README.md b/README.md index 1d238c5..ccf48f6 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,26 @@ src ``` Export your component in `src/index.ts` +### Naming +Custom elements all share one global registry, and that registry is shared with every other script on the host page. Give each element a name specific enough that nothing else would plausibly want it. + +If a component is built from several elements, prefix its parts with the component's own name rather than naming them generically, and keep the file name matching the tag: +``` +src +- elements + - ia-foobar + - ia-foobar.ts // ia-foobar + - ia-foobar-list-item.ts // ia-foobar-list-item, not ia-list-item +``` +Declare each element in `HTMLElementTagNameMap` so `querySelector` is typed and a mistyped tag in a template is caught at build time: +```ts +declare global { + interface HTMLElementTagNameMap { + 'ia-foobar-list-item': IAFoobarListItem; + } +} +``` + ### Story To demo your component, we have a component catalog that you can add your demo to. Create a component in your component directory. Name it `COMPONENT-NAME-story.ts`, ie `ia-button-story.ts`. diff --git a/src/elements/ia-item-navigator/element-names.test.ts b/src/elements/ia-item-navigator/element-names.test.ts new file mode 100644 index 0000000..563e9bf --- /dev/null +++ b/src/elements/ia-item-navigator/element-names.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'vitest'; + +import './ia-item-navigator'; +import './ia-itemnav-loading-view'; +import './ia-itemnav-menu-slider'; +import './ia-itemnav-menu-button'; +import './ia-itemnav-no-theater-available'; +import './menus/ia-itemnav-viewable-files-panel'; +import './menus/ia-itemnav-share-panel'; +import './menus/ia-itemnav-sort-files-button'; + +/** + * Custom elements share one global registry, so the navigator's parts are + * namespaced under its own prefix. Without that, names like `ia-menu-slider` + * collide with whatever else the host page has loaded — including older + * releases of this same component, where a second registration throws rather + * than degrading. + */ +const NAMESPACED_ELEMENTS = [ + 'ia-itemnav-loading-view', + 'ia-itemnav-menu-slider', + 'ia-itemnav-menu-button', + 'ia-itemnav-no-theater-available', + 'ia-itemnav-viewable-files-panel', + 'ia-itemnav-share-panel', + 'ia-itemnav-sort-files-button', +]; + +/** + * Names this component must not claim. The first four are generic enough that + * any page could want them; the last three are registered verbatim by the + * `@internetarchive/ia-item-navigator` package that consumers still load + * during the migration. + */ +const NAMES_TO_AVOID = [ + 'ia-menu-button', + 'ia-share-panel', + 'ia-viewable-files-panel', + 'ia-sort-files-button', + 'ia-menu-slider', + 'ia-itemnav-loader', + 'ia-no-theater-available', +]; + +describe('item navigator element names', () => { + test('registers the navigator under its own name', () => { + expect(customElements.get('ia-item-navigator')).to.exist; + }); + + test.each(NAMESPACED_ELEMENTS)('registers %s', (tag) => { + expect(customElements.get(tag), `${tag} should be registered`).to.exist; + }); + + test.each(NAMES_TO_AVOID)('leaves %s free for other components', (tag) => { + expect(customElements.get(tag), `${tag} should not be claimed`).to.not + .exist; + }); +}); diff --git a/src/elements/ia-item-navigator/ia-item-navigator-story.ts b/src/elements/ia-item-navigator/ia-item-navigator-story.ts index 531cce2..4ab295a 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator-story.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator-story.ts @@ -2,11 +2,11 @@ import { css, html, LitElement, nothing, type CSSResultGroup } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import './ia-item-navigator'; -import './menus/ia-viewable-files-panel'; -import './menus/ia-sort-files-button'; -import './menus/ia-share-panel'; -import { viewableFilesIcon } from './menus/ia-viewable-files-panel'; -import { shareIcon } from './menus/ia-share-panel'; +import './menus/ia-itemnav-viewable-files-panel'; +import './menus/ia-itemnav-sort-files-button'; +import './menus/ia-itemnav-share-panel'; +import { viewableFilesIcon } from './menus/ia-itemnav-viewable-files-panel'; +import { shareIcon } from './menus/ia-itemnav-share-panel'; import { maskedIcon } from './masked-icon'; import listIconUrl from './menus/icons/list.svg'; import type { @@ -303,19 +303,19 @@ export class IAItemNavigatorStory extends LitElement { label: `Viewable Files (${DEMO_FILES.length})`, icon: viewableFilesIcon, actionButton: html` - + > `, component: html` - this.handleFileClick(e)} - > + > `, }, { @@ -324,12 +324,12 @@ export class IAItemNavigatorStory extends LitElement { label: 'Share this item', icon: shareIcon, component: html` - + > `, }, { diff --git a/src/elements/ia-item-navigator/ia-item-navigator.test.ts b/src/elements/ia-item-navigator/ia-item-navigator.test.ts index e6ecd93..47a7c12 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.test.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.test.ts @@ -4,8 +4,8 @@ import { describe, expect, test, vi } from 'vitest'; import { IAItemNavigator } from './ia-item-navigator'; import './ia-item-navigator'; -import type { IAMenuSlider } from './ia-menu-slider'; -import type { IANoTheaterAvailable } from './ia-no-theater-available'; +import type { IAItemNavMenuSlider } from './ia-itemnav-menu-slider'; +import type { IAItemNavNoTheaterAvailable } from './ia-itemnav-no-theater-available'; import type { MenuProviderInterface, MenuShortcutInterface, @@ -64,12 +64,13 @@ describe('IAItemNavigator', () => { html``, ); - expect(el.shadowRoot?.querySelector('ia-itemnav-loader')).to.exist; + expect(el.shadowRoot?.querySelector('ia-itemnav-loading-view')).to.exist; el.loaded = true; await el.updateComplete; - expect(el.shadowRoot?.querySelector('ia-itemnav-loader')).to.not.exist; + expect(el.shadowRoot?.querySelector('ia-itemnav-loading-view')).to.not + .exist; }); test('renders no side menu when there are no providers', async () => { @@ -91,7 +92,7 @@ describe('IAItemNavigator', () => { expect(el.shouldRenderMenu).to.equal(true); expect(el.shadowRoot?.querySelector('nav')).to.exist; expect(el.shadowRoot?.querySelector('button.toggle-menu')).to.exist; - expect(el.shadowRoot?.querySelector('ia-menu-slider')).to.exist; + expect(el.shadowRoot?.querySelector('ia-itemnav-menu-slider')).to.exist; }); test('toggleMenu / closeMenu drive the menuOpened state', async () => { @@ -142,7 +143,9 @@ describe('IAItemNavigator', () => { el.openMenu = 'about'; await el.updateComplete; - const slider = el.shadowRoot?.querySelector('ia-menu-slider'); + const slider = el.shadowRoot?.querySelector( + 'ia-itemnav-menu-slider', + ); expect(slider).to.exist; expect(slider?.menus).to.have.lengthOf(2); expect(slider?.selectedMenu).to.equal('about'); @@ -157,9 +160,10 @@ describe('IAItemNavigator', () => { el.viewAvailable = false; await el.updateComplete; - const placeholder = el.shadowRoot?.querySelector( - 'ia-no-theater-available', - ); + const placeholder = + el.shadowRoot?.querySelector( + 'ia-itemnav-no-theater-available', + ); expect(placeholder).to.exist; expect(placeholder?.identifier).to.equal('abc123'); }); @@ -270,7 +274,7 @@ describe('IAItemNavigator', () => { el.openShortcut('contents'); await el.updateComplete; - const slider = el.shadowRoot?.querySelector('ia-menu-slider'); + const slider = el.shadowRoot?.querySelector('ia-itemnav-menu-slider'); slider?.dispatchEvent( new CustomEvent('menuPanelClosed', { detail: { id: 'contents' }, diff --git a/src/elements/ia-item-navigator/ia-item-navigator.ts b/src/elements/ia-item-navigator/ia-item-navigator.ts index 3ee8a11..58cc841 100644 --- a/src/elements/ia-item-navigator/ia-item-navigator.ts +++ b/src/elements/ia-item-navigator/ia-item-navigator.ts @@ -13,10 +13,10 @@ import { MetadataResponse } from '@internetarchive/metadata-service'; import themeStyles from '@src/themes/theme-styles'; import { ellipsesIcon } from './icons'; -import './ia-menu-slider'; -import './ia-itemnav-loader'; -import './ia-no-theater-available'; -import type { IAMenuSlider } from './ia-menu-slider'; +import './ia-itemnav-menu-slider'; +import './ia-itemnav-loading-view'; +import './ia-itemnav-no-theater-available'; +import type { IAItemNavMenuSlider } from './ia-itemnav-menu-slider'; import { ToggleSideMenuOpenEvent, @@ -40,7 +40,7 @@ import { /** * A fullscreen-capable frame that hosts an Archive.org item's theater. The * consumer projects a header bar and the theater itself into the `header` and - * `main` slots; a collapsible left drawer (`ia-menu-slider`) is driven by a + * `main` slots; a collapsible left drawer (`ia-itemnav-menu-slider`) is driven by a * data array of menu providers, and a minimized rail shows shortcut icons. * * The navigator is a shell — it does not know how to render any particular @@ -94,7 +94,7 @@ export class IAItemNavigator @query('slot[name="header"]') private headerSlot!: HTMLSlotElement; - @query('ia-menu-slider') private menuSlider!: IAMenuSlider; + @query('ia-itemnav-menu-slider') private menuSlider!: IAItemNavMenuSlider; @query('button.toggle-menu') private toggleMenuButton!: HTMLButtonElement; @@ -160,9 +160,9 @@ export class IAItemNavigator return html`
    - + >
    `; @@ -204,10 +204,10 @@ export class IAItemNavigator } get noTheaterView(): TemplateResult { - return html``; + >`; } get renderViewport(): TemplateResult | typeof nothing { @@ -252,7 +252,7 @@ export class IAItemNavigator toggleMenu(forceValue: boolean | undefined = undefined): void { this.menuOpened = forceValue !== undefined ? forceValue : !this.menuOpened; if (this.menuOpened) { - // Move focus to the + // Move focus to the this.updateComplete.then(() => { const closeButton = this.menuSlider?.shadowRoot?.querySelector( 'button.close', @@ -337,7 +337,7 @@ export class IAItemNavigator ${this.shortcuts} ${this.menuToggleButton}
    `; @@ -506,7 +506,7 @@ export class IAItemNavigator width: 100%; } - ia-itemnav-loader { + ia-itemnav-loading-view { display: block; width: 100%; } @@ -653,3 +653,9 @@ export class IAItemNavigator ]; } } + +declare global { + interface HTMLElementTagNameMap { + 'ia-item-navigator': IAItemNavigator; + } +} diff --git a/src/elements/ia-item-navigator/ia-itemnav-loader.test.ts b/src/elements/ia-item-navigator/ia-itemnav-loading-view.test.ts similarity index 59% rename from src/elements/ia-item-navigator/ia-itemnav-loader.test.ts rename to src/elements/ia-item-navigator/ia-itemnav-loading-view.test.ts index 9a5d617..6f33282 100644 --- a/src/elements/ia-item-navigator/ia-itemnav-loader.test.ts +++ b/src/elements/ia-item-navigator/ia-itemnav-loading-view.test.ts @@ -2,13 +2,13 @@ import { fixture } from '@open-wc/testing-helpers'; import { html } from 'lit'; import { describe, expect, test } from 'vitest'; -import { IAItemNavLoader } from './ia-itemnav-loader'; -import './ia-itemnav-loader'; +import { IAItemNavLoadingView } from './ia-itemnav-loading-view'; +import './ia-itemnav-loading-view'; -describe('IAItemNavLoader', () => { +describe('IAItemNavLoadingView', () => { test('renders the loading spinner', async () => { - const el = await fixture( - html``, + const el = await fixture( + html``, ); expect(el.shadowRoot?.querySelector('svg .ring')).to.exist; expect(el.shadowRoot?.querySelector('h3')?.textContent).to.contain( @@ -17,10 +17,10 @@ describe('IAItemNavLoader', () => { }); test('shows the loader message when provided', async () => { - const el = await fixture( - html`( + html``, + >`, ); expect(el.shadowRoot?.querySelector('h2')?.textContent).to.contain( 'Internet Archive', @@ -28,8 +28,8 @@ describe('IAItemNavLoader', () => { }); test('omits the message heading when there is no message', async () => { - const el = await fixture( - html``, + const el = await fixture( + html``, ); expect(el.shadowRoot?.querySelector('h2')).to.not.exist; }); diff --git a/src/elements/ia-item-navigator/ia-itemnav-loader.ts b/src/elements/ia-item-navigator/ia-itemnav-loading-view.ts similarity index 95% rename from src/elements/ia-item-navigator/ia-itemnav-loader.ts rename to src/elements/ia-item-navigator/ia-itemnav-loading-view.ts index 769c091..f2d6b2e 100644 --- a/src/elements/ia-item-navigator/ia-itemnav-loader.ts +++ b/src/elements/ia-item-navigator/ia-itemnav-loading-view.ts @@ -15,8 +15,8 @@ import themeStyles from '@src/themes/theme-styles'; * The spinning "loading viewer" placeholder shown inside the navigator frame * until the slotted theater reports that it has loaded. */ -@customElement('ia-itemnav-loader') -export class IAItemNavLoader extends LitElement { +@customElement('ia-itemnav-loading-view') +export class IAItemNavLoadingView extends LitElement { @property({ type: String }) loaderMessage = ''; get bookIconSvg(): SVGTemplateResult { @@ -118,3 +118,9 @@ export class IAItemNavLoader extends LitElement { ]; } } + +declare global { + interface HTMLElementTagNameMap { + 'ia-itemnav-loading-view': IAItemNavLoadingView; + } +} diff --git a/src/elements/ia-item-navigator/ia-menu-button.test.ts b/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts similarity index 73% rename from src/elements/ia-item-navigator/ia-menu-button.test.ts rename to src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts index 908b532..44ca754 100644 --- a/src/elements/ia-item-navigator/ia-menu-button.test.ts +++ b/src/elements/ia-item-navigator/ia-itemnav-menu-button.test.ts @@ -2,14 +2,14 @@ import { fixture } from '@open-wc/testing-helpers'; import { html } from 'lit'; import { describe, expect, test, vi } from 'vitest'; -import { IAMenuButton } from './ia-menu-button'; -import './ia-menu-button'; +import { IAItemNavMenuButton } from './ia-itemnav-menu-button'; +import './ia-itemnav-menu-button'; import { maskedIcon } from './masked-icon'; -describe('IAMenuButton', () => { +describe('IAItemNavMenuButton', () => { test('renders a