diff --git a/package.json b/package.json index 095189d..497f589 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@internetarchive/elements", - "version": "0.2.2", + "version": "0.2.2-webdev-8356.2", "description": "A web component library from the Internet Archive.", "license": "AGPL-3.0-only", "types": "./dist/src/elements/index.d.ts", diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts new file mode 100644 index 0000000..1a6e42e --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts @@ -0,0 +1,148 @@ +import { html, LitElement } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; + +import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; +import type { StyleInputSettings } from '@demo/story-components/story-styles-settings'; +import type { IAZendeskWidget } from './ia-zendesk-widget'; + +import '@demo/story-template'; +import './ia-zendesk-widget'; +import '../ia-button/ia-button'; + +// Test-account key — the host app should supply its own key in production. +const TEST_WIDGET_KEY = '6fe87bd8-d4e3-4b42-8632-be6eb933d54d'; + +const propInputSettings: PropInputSettings[] = [ + { + label: 'Widget Key', + propertyName: 'widgetKey', + defaultValue: TEST_WIDGET_KEY, + }, + { + label: 'Breakpoint (px)', + propertyName: 'breakpoint', + defaultValue: '767', + }, +]; + +const styleInputSettings: StyleInputSettings[] = [ + // Colors + { + label: 'Background', + cssVariable: '--button-background', + defaultValue: '#194880', + inputType: 'color', + }, + { + label: 'Text / Icon Color', + cssVariable: '--button-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Icon Fill (independent)', + cssVariable: '--icon-fill-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + // Size + { + label: 'Width', + cssVariable: '--button-width', + defaultValue: 'auto', + }, + { + label: 'Padding', + cssVariable: '--button-padding', + defaultValue: '14px', + }, + // Position + { + label: 'Margin', + cssVariable: '--button-margin', + defaultValue: '14px 20px', + }, + { + label: 'Top', + cssVariable: '--button-top', + defaultValue: 'auto', + }, + { + label: 'Bottom', + cssVariable: '--button-bottom', + defaultValue: '0', + }, + { + label: 'Left', + cssVariable: '--button-left', + defaultValue: 'auto', + }, + { + label: 'Right', + cssVariable: '--button-right', + defaultValue: '0', + }, + { + label: 'Z-Index', + cssVariable: '--button-z-index', + defaultValue: '999998', + }, + // Shape + { + label: 'Border Radius', + cssVariable: '--button-border-radius', + defaultValue: '3rem', + }, + // Typography + { + label: 'Font Size', + cssVariable: '--button-font-size', + defaultValue: '14px', + }, + { + label: 'Font Weight', + cssVariable: '--button-font-weight', + defaultValue: '700', + }, +]; + +@customElement('ia-zendesk-widget-story') +export class IAZendeskWidgetStory extends LitElement { + @state() private widgetMounted = false; + + private activateWidget(): void { + this.widgetMounted = true; + } + + private get usageExample(): string { + return ` + ,`; + } + + render() { + return html` + + + + + ${this.widgetMounted ? 'Activated!' : 'Activate help widget'} + + + `; + } +} diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts new file mode 100644 index 0000000..fec7f16 --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts @@ -0,0 +1,211 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test, vi, afterEach, beforeEach } from 'vitest'; +import { html } from 'lit'; + +import type { IAZendeskWidget } from './ia-zendesk-widget'; +import './ia-zendesk-widget'; + +const WIDGET_KEY = 'test-key'; + +/** + * Creates a matchMedia stub and returns helpers to assert on it and fire + * change events. Must be called before the element is connected to the DOM + * so that connectedCallback picks up the mock. + */ +function mockMatchMedia(matches: boolean) { + const listeners: ((e: MediaQueryListEvent) => void)[] = []; + const mql = { + matches, + addEventListener: vi.fn( + (_: string, cb: (e: MediaQueryListEvent) => void) => { + listeners.push(cb); + }, + ), + removeEventListener: vi.fn(), + }; + vi.spyOn(window, 'matchMedia').mockReturnValue( + mql as unknown as MediaQueryList, + ); + return { + mql, + fireChange: (newMatches: boolean) => + listeners.forEach((cb) => + cb({ matches: newMatches } as MediaQueryListEvent), + ), + }; +} + +describe('IAZendeskWidget', () => { + beforeEach(() => { + // jsdom does not implement matchMedia; provide a non-compact default so + // connectedCallback does not throw in tests that don't need breakpoint coverage. + mockMatchMedia(false); + }); + + afterEach(() => { + document.getElementById('ze-snippet')?.remove(); + delete (window as any).zE; + vi.restoreAllMocks(); + }); + + describe('rendering', () => { + test('renders the Help button', async () => { + const el = await fixture( + html``, + ); + const btn = el.shadowRoot?.querySelector('.help-widget'); + expect(btn).toBeTruthy(); + }); + + test('button is visible by default', async () => { + const el = await fixture( + html``, + ); + const btn = el.shadowRoot?.querySelector('.help-widget'); + expect(btn?.classList.contains('hidden')).toBe(false); + }); + + test('button shows "Help" label text', async () => { + const el = await fixture( + html``, + ); + const label = el.shadowRoot?.querySelector('.label'); + expect(label?.textContent?.trim()).toBe('Help'); + }); + + test('renders question icon when idle', async () => { + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('.icon-question')).toBeTruthy(); + expect(el.shadowRoot?.querySelector('.icon-loader')).toBeFalsy(); + }); + + test('renders loader icon when isLoading is true', async () => { + const el = await fixture( + html``, + ); + (el as any).isLoading = true; + await el.updateComplete; + expect(el.shadowRoot?.querySelector('.icon-loader')).toBeTruthy(); + expect(el.shadowRoot?.querySelector('.icon-question')).toBeFalsy(); + }); + + test('button gets hidden class when buttonVisible is false', async () => { + const el = await fixture( + html``, + ); + (el as any).buttonVisible = false; + await el.updateComplete; + const btn = el.shadowRoot?.querySelector('.help-widget'); + expect(btn?.classList.contains('hidden')).toBe(true); + }); + }); + + describe('zendeskHelpButtonClicked event', () => { + test('fires when the Help button is clicked', async () => { + const el = await fixture( + html``, + ); + + (el as any).initiateZenDesk = vi.fn(async () => { + el.dispatchEvent(new Event('zendeskHelpButtonClicked')); + }); + + let fired = false; + el.addEventListener('zendeskHelpButtonClicked', () => { + fired = true; + }); + + el.shadowRoot?.querySelector('.help-widget')?.click(); + await el.updateComplete; + + expect(fired).toBe(true); + }); + }); + + describe('breakpoint / compact mode', () => { + test('shows "Help" label when viewport is wider than breakpoint', async () => { + mockMatchMedia(false); + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('.label')).toBeTruthy(); + }); + + test('hides "Help" label when viewport is narrower than breakpoint', async () => { + mockMatchMedia(true); + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('.label')).toBeFalsy(); + }); + + test('hides label when viewport shrinks below breakpoint at runtime', async () => { + const { fireChange } = mockMatchMedia(false); + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('.label')).toBeTruthy(); + + fireChange(true); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.label')).toBeFalsy(); + }); + + test('shows label when viewport grows above breakpoint at runtime', async () => { + const { fireChange } = mockMatchMedia(true); + const el = await fixture( + html``, + ); + expect(el.shadowRoot?.querySelector('.label')).toBeFalsy(); + + fireChange(false); + await el.updateComplete; + + expect(el.shadowRoot?.querySelector('.label')).toBeTruthy(); + }); + + test('uses default 767px breakpoint in media query', async () => { + await fixture( + html``, + ); + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 767px)'); + }); + + test('uses custom breakpoint in media query', async () => { + await fixture( + html``, + ); + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 480px)'); + }); + + test('rebuilds media query when breakpoint property changes', async () => { + const { mql } = mockMatchMedia(false); + const el = await fixture( + html``, + ); + + el.breakpoint = 480; + await el.updateComplete; + + expect(mql.removeEventListener).toHaveBeenCalled(); + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 480px)'); + }); + + test('removes media query listener when element disconnects', async () => { + const { mql } = mockMatchMedia(false); + const el = await fixture( + html``, + ); + + el.remove(); + + expect(mql.removeEventListener).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts b/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts new file mode 100644 index 0000000..0c8fe56 --- /dev/null +++ b/src/elements/ia-zendesk-widget/ia-zendesk-widget.ts @@ -0,0 +1,231 @@ +import { + css, + html, + nothing, + LitElement, + type CSSResultGroup, + type PropertyValues, + type SVGTemplateResult, + type TemplateResult, +} from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { loaderIcon } from './loader-icon'; +import { questionIcon } from './question-icon'; +import { loadZendeskScript, waitForZendesk } from './zendesk-service'; + +/** + * A lightweight launcher button that loads and opens the Zendesk Messenger + * widget on demand. + * + * The native Zendesk launcher is never shown — this button is the sole entry + * point. The Zendesk snippet script is fetched lazily on the first click so + * it does not affect page load performance. + * + * @fires zendeskHelpButtonClicked - Dispatched when the user clicks the Help + * button, before the widget is opened. + * + * @cssprop [--button-background=#194880] - Button background colour. + * @cssprop [--button-color=#fff] - Button text and icon colour. + * @cssprop [--icon-fill-color=var(--button-color)] - SVG icon fill; defaults to `--button-color`. + * @cssprop [--button-width=auto] - Button width. + * @cssprop [--button-padding=13px] - Button padding (overrides fixed width/height when set). + * @cssprop [--button-margin=14px 20px] - Margin between button and viewport edges. + * @cssprop [--button-top=auto] - Distance from the top of the viewport. + * @cssprop [--button-bottom=0] - Distance from the bottom of the viewport. + * @cssprop [--button-left=auto] - Distance from the left of the viewport. + * @cssprop [--button-right=0] - Distance from the right of the viewport. + * @cssprop [--button-z-index=999998] - Stack order. + * @cssprop [--button-border-radius=999rem] - Border radius. + * @cssprop [--button-font-size=14px] - Font size. + * @cssprop [--button-font-weight=700] - Font weight. + * + * @prop {number} [breakpoint=767] - Viewport width (px) below which the label is automatically hidden. + * + * @example + * ```html + * + * ``` + * + * @example Customised appearance + * ```html + * + * ``` + */ +@customElement('ia-zendesk-widget') +export class IAZendeskWidget extends LitElement { + /** Zendesk account key from the `ze-snippet` URL. */ + @property({ type: String }) + widgetKey = '6fe87bd8-d4e3-4b42-8632-be6eb933d54d'; + + /** Viewport width (px) below which the label is automatically hidden. */ + @property({ type: Number }) + breakpoint = 767; + + /** Controls Help button visibility. Hidden while the widget panel is open. */ + @state() private buttonVisible = true; + + /** True from the first click until Zendesk fires the `open` event. Shows the spinner. */ + @state() private isLoading = false; + + /** True when the viewport is narrower than `breakpoint`. */ + @state() private isCompact = false; + + private _mql?: MediaQueryList; + private _onMqlChange = (e: MediaQueryListEvent) => { + this.isCompact = e.matches; + }; + + /** + * Set to `true` after the snippet has loaded and the `messenger:on` listeners + * have been registered. Prevents duplicate listener registration on + * subsequent clicks. + */ + private zendeskReady = false; + + connectedCallback(): void { + super.connectedCallback(); + this._setupMediaQuery(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this._mql?.removeEventListener('change', this._onMqlChange); + } + + updated(changed: PropertyValues): void { + if (changed.has('breakpoint')) { + this._mql?.removeEventListener('change', this._onMqlChange); + this._setupMediaQuery(); + } + } + + private _setupMediaQuery(): void { + this._mql = window.matchMedia(`(max-width: ${this.breakpoint}px)`); + this.isCompact = this._mql.matches; + this._mql.addEventListener('change', this._onMqlChange); + } + + /** + * Click handler for the Help button. + * + * On the first call: injects the Zendesk script, waits for `window.zE` to + * become available, registers open/close listeners, then opens the panel. + * + * On subsequent calls: the script is already present so we go straight to + * opening the panel. The spinner stays on until Zendesk fires `'open'`. + */ + private async initiateZenDesk(): Promise { + this.isLoading = true; + this.dispatchEvent(new Event('zendeskHelpButtonClicked')); + + try { + if (!this.zendeskReady) { + await loadZendeskScript(this.widgetKey); + await waitForZendesk(); + + if (!window.zE) { + this.isLoading = false; + return; + } + + // Register lifecycle listeners exactly once. + // isLoading is cleared here (not earlier) so the spinner persists until + // the widget panel is actually visible to the user. + window.zE('messenger:on', 'open', () => { + this.buttonVisible = false; + this.isLoading = false; + }); + + // Delay matches the Zendesk close animation so the Help button does not + // reappear while the panel is still sliding out. + window.zE('messenger:on', 'close', () => { + setTimeout(() => { + this.buttonVisible = true; + }, 500); + }); + + this.zendeskReady = true; + } + + if (window.zE) { + window.zE('messenger', 'open'); + } + } catch (err) { + this.isLoading = false; + console.error('[ia-zendesk-widget]', err); + } + } + + private get iconTemplate(): SVGTemplateResult { + return this.isLoading ? loaderIcon : questionIcon; + } + + render(): TemplateResult { + return html` + + `; + } + + static get styles(): CSSResultGroup { + return css` + .help-widget { + position: fixed; + top: var(--button-top, auto); + bottom: var(--button-bottom, 0); + left: var(--button-left, auto); + right: var(--button-right, 0); + z-index: var(--button-z-index, 999998); + width: var(--button-width, auto); + padding: var(--button-padding, 14px); + margin: var(--button-margin, 14px 20px); + background: var(--button-background, #194880); + color: var(--button-color, #fff); + border-radius: var(--button-border-radius, 999rem); + border: 0; + font-size: var(--button-font-size, 14px); + font-weight: var(--button-font-weight, 700); + letter-spacing: 0.6px; + outline: none; + cursor: pointer; + vertical-align: middle; + transition: opacity 0.12s linear; + } + + .fill-color { + fill: var(--button-color, #fff); + } + + .help-widget svg { + vertical-align: middle; + pointer-events: none; + } + + .label { + pointer-events: none; + margin-right: 3px; + } + + .hidden { + opacity: 0; + display: none; + visibility: hidden; + } + `; + } +} diff --git a/src/elements/ia-zendesk-widget/loader-icon.ts b/src/elements/ia-zendesk-widget/loader-icon.ts new file mode 100644 index 0000000..ffebe76 --- /dev/null +++ b/src/elements/ia-zendesk-widget/loader-icon.ts @@ -0,0 +1,30 @@ +import { svg } from 'lit'; + +export const loaderIcon = svg` + + + + + + + + + + + + + +`; diff --git a/src/elements/ia-zendesk-widget/question-icon.ts b/src/elements/ia-zendesk-widget/question-icon.ts new file mode 100644 index 0000000..af961c7 --- /dev/null +++ b/src/elements/ia-zendesk-widget/question-icon.ts @@ -0,0 +1,31 @@ +import { svg } from 'lit'; + +export const questionIcon = svg` + + + + + + + +`; diff --git a/src/elements/ia-zendesk-widget/zendesk-service.ts b/src/elements/ia-zendesk-widget/zendesk-service.ts new file mode 100644 index 0000000..75bec6f --- /dev/null +++ b/src/elements/ia-zendesk-widget/zendesk-service.ts @@ -0,0 +1,62 @@ +declare global { + interface Window { + /** + * Zendesk Messenger API injected by the ze-snippet script. + * Overloaded to cover the two call shapes we use: + * - `messenger:on` — subscribe to open/close lifecycle events + * - `messenger` — imperatively open or close the widget panel + */ + zE?: { + ( + target: 'messenger:on', + event: 'open' | 'close', + callback: () => void, + ): void; + (target: 'messenger', action: 'open' | 'close'): void; + }; + } +} + +const SNIPPET_BASE_URL = 'https://static.zdassets.com/ekr/snippet.js'; + +/** + * Appends the Zendesk snippet `