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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
148 changes: 148 additions & 0 deletions src/elements/ia-zendesk-widget/ia-zendesk-widget-story.ts
Original file line number Diff line number Diff line change
@@ -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<IAZendeskWidget>[] = [
{
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 `<ia-zendesk-widget
.widgetKey="${TEST_WIDGET_KEY}">
</ia-zendesk-widget>,`;
}

render() {
return html`
<story-template
elementTag="ia-zendesk-widget"
elementClassName="IAZendeskWidget"
.customExampleUsage=${this.usageExample}
.styleInputData=${{ settings: styleInputSettings }}
.propInputData=${{ settings: propInputSettings }}
>
<ia-zendesk-widget
slot="demo"
.widgetKey=${TEST_WIDGET_KEY}
style="${this.widgetMounted ? '' : 'display:none'}"
></ia-zendesk-widget>

<ia-button
slot="demo"
@click=${this.activateWidget}
?disabled=${this.widgetMounted}
>
${this.widgetMounted ? 'Activated!' : 'Activate help widget'}
</ia-button>
</story-template>
`;
}
}
211 changes: 211 additions & 0 deletions src/elements/ia-zendesk-widget/ia-zendesk-widget.test.ts
Original file line number Diff line number Diff line change
@@ -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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
const btn = el.shadowRoot?.querySelector('.help-widget');
expect(btn).toBeTruthy();
});

test('button is visible by default', async () => {
const el = await fixture<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
const label = el.shadowRoot?.querySelector('.label');
expect(label?.textContent?.trim()).toBe('Help');
});

test('renders question icon when idle', async () => {
const el = await fixture<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
(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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
(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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);

(el as any).initiateZenDesk = vi.fn(async () => {
el.dispatchEvent(new Event('zendeskHelpButtonClicked'));
});

let fired = false;
el.addEventListener('zendeskHelpButtonClicked', () => {
fired = true;
});

el.shadowRoot?.querySelector<HTMLButtonElement>('.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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
expect(el.shadowRoot?.querySelector('.label')).toBeTruthy();
});

test('hides "Help" label when viewport is narrower than breakpoint', async () => {
mockMatchMedia(true);
const el = await fixture<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 767px)');
});

test('uses custom breakpoint in media query', async () => {
await fixture<IAZendeskWidget>(
html`<ia-zendesk-widget
.widgetKey=${WIDGET_KEY}
.breakpoint=${480}
></ia-zendesk-widget>`,
);
expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 480px)');
});

test('rebuilds media query when breakpoint property changes', async () => {
const { mql } = mockMatchMedia(false);
const el = await fixture<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);

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<IAZendeskWidget>(
html`<ia-zendesk-widget .widgetKey=${WIDGET_KEY}></ia-zendesk-widget>`,
);

el.remove();

expect(mql.removeEventListener).toHaveBeenCalled();
});
});
});
Loading
Loading