diff --git a/demo/app-root.ts b/demo/app-root.ts index 181ebed..5d018ab 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -1,26 +1,101 @@ import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; +// unsafeHTML is needed to render dynamic custom-element tag names; +// Lit's html`` tag cannot render variable tag names directly. +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import '@src/elements/ia-button/ia-button-story'; -import '@src/labs/ia-snow/ia-snow-story'; -import '@src/elements/ia-combo-box/ia-combo-box-story'; -import '@src/elements/ia-status-indicator/ia-status-indicator-story'; +const storyModules = import.meta.glob( + ['../src/elements/**/*-story.ts', '../src/labs/**/*-story.ts'], + { eager: true } +); + +const storyEntries = Object.keys(storyModules) + .map(path => { + const labs = path.includes('/src/labs/'); + const parts = path.split('/'); + const tag = parts[parts.length - 2]; + return { tag, storyTag: `${tag}-story`, id: `elem-${tag}`, labs }; + }) + .sort((a, b) => a.tag.localeCompare(b.tag)); + +const productionEntries = storyEntries.filter(e => !e.labs); +const labsEntries = storyEntries.filter(e => e.labs); +const ALL_ENTRIES = [...productionEntries, ...labsEntries]; @customElement('app-root') export class AppRoot extends LitElement { + createRenderRoot() { return this; } + + private _observer?: IntersectionObserver; + render() { return html` -

๐Ÿ›๏ธ Internet Archive Elements โš›๏ธ

+ +
+

Internet Archive Elements

+

Production-Ready Elements

+ ${productionEntries.map(e => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `)} +

Labs Elements

+ ${labsEntries.map(e => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `)} +
+ `; + } -

๐Ÿš€ Production-Ready Elements

+ firstUpdated() { + const allIds = ALL_ENTRIES.map(e => e.id); - - + const links = Object.fromEntries( + allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + ); -

๐Ÿงช Labs Elements

+ const visible = new Set(); - - - `; + // Only anchors in the top 30% of the viewport count as "active". + // The first (topmost) visible anchor wins. + this._observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) visible.add(entry.target.id); + else visible.delete(entry.target.id); + } + const activeId = allIds.find(id => visible.has(id)) ?? allIds[0]; + allIds.forEach(id => links[id]?.classList.toggle('active', id === activeId)); + }, + { rootMargin: '0px 0px -70% 0px' }, + ); + + allIds.forEach(id => { + const el = document.getElementById(id); + if (el) this._observer!.observe(el); + }); + + allIds.forEach(id => { + links[id]?.addEventListener('click', (e: Event) => { + e.preventDefault(); + const el = document.getElementById(id); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + } + }); + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this._observer?.disconnect(); } } diff --git a/demo/index.css b/demo/index.css index ce98d39..ad8f43f 100644 --- a/demo/index.css +++ b/demo/index.css @@ -1,5 +1,5 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400; @@ -24,7 +24,89 @@ a:hover { } body { - margin: 1rem; + margin: 0; min-width: 320px; min-height: 100vh; + display: flex; +} + +app-root { + display: contents; +} + +#ia-sidebar { + width: 200px; + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid #ddd; + padding: 1rem 0; + box-sizing: border-box; + overflow-y: auto; +} + +#ia-sidebar h2 { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #767676; + padding: 0 1rem; + margin: 0 0 0.5rem; +} + +#ia-sidebar a { + display: block; + font-size: 0.82rem; + font-weight: 400; + color: #444; + text-decoration: none; + padding: 0.3rem 1rem; + border-left: 3px solid transparent; + transition: background 0.1s, color 0.1s, border-color 0.1s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#ia-sidebar a:hover { + background: #f0f0f0; + color: #213547; +} + +#ia-sidebar a.active { + color: #194880; + border-left-color: #194880; + background: #f0f4f9; + font-weight: 600; +} + +#ia-content { + flex: 1; + padding: 0.75rem 1rem; + padding-bottom: 100vh; + min-width: 0; +} + +.ia-anchor { + scroll-margin-top: 16px; +} + +.ia-anchor > * { + display: block; + margin-bottom: 0.5rem; +} + +#ia-content h1 { + font-size: 1.4rem; + margin: 0 0 0.25rem; + display: flex; + align-items: center; +} + +#ia-content h2 { + font-size: 1.1rem; + font-weight: 600; + margin: 0.75rem 0 0.25rem; } diff --git a/demo/story-components/story-prop-settings.ts b/demo/story-components/story-prop-settings.ts index 7839d02..e2533df 100644 --- a/demo/story-components/story-prop-settings.ts +++ b/demo/story-components/story-prop-settings.ts @@ -45,7 +45,6 @@ export class StoryPropsSettings extends LitElement { if (!this.propInputData) return nothing; return html` -

Properties

${this.propInputData.settings.map( diff --git a/demo/story-components/story-styles-settings.ts b/demo/story-components/story-styles-settings.ts index 8067f6b..253e764 100644 --- a/demo/story-components/story-styles-settings.ts +++ b/demo/story-components/story-styles-settings.ts @@ -30,7 +30,6 @@ export class StoryStylesSettings extends LitElement { if (!this.styleInputData) return nothing; return html` -

Styles

${this.styleInputData.settings.map( diff --git a/demo/story-template.test.ts b/demo/story-template.test.ts new file mode 100644 index 0000000..4aa2cd8 --- /dev/null +++ b/demo/story-template.test.ts @@ -0,0 +1,269 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { html } from 'lit'; + +import type { StoryTemplate } from './story-template'; +import './story-template'; + +describe('StoryTemplate', () => { + describe('importCode', () => { + test('includes both side-effect and named import when elementClassName is provided', async () => { + const el = await fixture(html` + + `); + + const importHighlighter = el.shadowRoot?.querySelectorAll( + 'syntax-highlighter', + )[0] as any; + expect(importHighlighter).to.exist; + + const code: string = importHighlighter.code; + expect(code).to.include( + "import '@internetarchive/elements/ia-button/ia-button';", + ); + expect(code).to.include( + "import { IAButton } from '@internetarchive/elements/ia-button/ia-button';", + ); + }); + + test('includes only the side-effect import when elementClassName is not provided', async () => { + const el = await fixture(html` + + `); + + const importHighlighter = el.shadowRoot?.querySelectorAll( + 'syntax-highlighter', + )[0] as any; + expect(importHighlighter).to.exist; + + const code: string = importHighlighter.code; + expect(code).to.equal( + "import '@internetarchive/elements/ia-button/ia-button';", + ); + }); + + test('has no leading or trailing whitespace', async () => { + const el = await fixture(html` + + `); + + const importHighlighter = el.shadowRoot?.querySelectorAll( + 'syntax-highlighter', + )[0] as any; + const code: string = importHighlighter.code; + expect(code).to.equal(code.trim()); + }); + }); + + describe('cssCode', () => { + test('does not render styling section when stringifiedStyles is not set', async () => { + const el = await fixture(html` + + `); + + // Only import + usage highlighters; styling section is absent when cssCode is empty + const highlighters = el.shadowRoot?.querySelectorAll('syntax-highlighter'); + expect(highlighters?.length).to.equal(2); + }); + + test('renders CSS block with element tag wrapping the styles', async () => { + const el = await fixture(html` + + `); + + (el as any).stringifiedStyles = 'color: red;'; + await el.updateComplete; + + const highlighters = el.shadowRoot?.querySelectorAll('syntax-highlighter'); + expect(highlighters?.length).to.equal(3); + + const stylingHighlighter = highlighters?.[2] as any; + expect(stylingHighlighter.code).to.equal( + 'ia-button {\n color: red;\n}', + ); + }); + + test('has no trailing whitespace on any line', async () => { + const el = await fixture(html` + + `); + + (el as any).stringifiedStyles = '--my-var: blue;'; + await el.updateComplete; + + const highlighters = el.shadowRoot?.querySelectorAll('syntax-highlighter'); + const code: string = (highlighters?.[2] as any).code; + for (const line of code.split('\n')) { + expect(line).to.equal(line.trimEnd()); + } + }); + }); + + describe('Details toggle', () => { + test('#details has collapsed class on initial render', async () => { + const el = await fixture(html` + + `); + + const details = el.shadowRoot?.querySelector('#details'); + expect(details?.classList.contains('collapsed')).to.be.true; + }); + + test('clicking .details-toggle removes collapsed class from #details', async () => { + const el = await fixture(html` + + `); + + const toggleBtn = el.shadowRoot?.querySelector( + '.details-toggle', + ) as HTMLButtonElement; + expect(toggleBtn).to.exist; + + toggleBtn.click(); + await el.updateComplete; + + const details = el.shadowRoot?.querySelector('#details'); + expect(details?.classList.contains('collapsed')).to.be.false; + }); + + test('clicking .details-toggle a second time restores collapsed class', async () => { + const el = await fixture(html` + + `); + + const toggleBtn = el.shadowRoot?.querySelector( + '.details-toggle', + ) as HTMLButtonElement; + const details = el.shadowRoot?.querySelector('#details'); + + toggleBtn.click(); + await el.updateComplete; + expect(details?.classList.contains('collapsed')).to.be.false; + + toggleBtn.click(); + await el.updateComplete; + expect(details?.classList.contains('collapsed')).to.be.true; + }); + }); + + describe('Copy buttons', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + test('clicking the import copy button changes button text to "Copied!"', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const el = await fixture(html` + + `); + + const importCopyBtn = el.shadowRoot?.querySelectorAll( + '.copy-btn', + )[0] as HTMLButtonElement; + expect(importCopyBtn.textContent?.trim()).to.equal('Copy'); + + importCopyBtn.click(); + // Flush the clipboard promise microtasks before awaiting re-render + await Promise.resolve(); + await Promise.resolve(); + await el.updateComplete; + + expect(importCopyBtn.textContent?.trim()).to.equal('Copied!'); + }); + + test('button text resets to "Copy" after 2 seconds', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const el = await fixture(html` + + `); + + // Start fake timers only after fixture is ready to avoid interfering with setup + vi.useFakeTimers(); + + const importCopyBtn = el.shadowRoot?.querySelectorAll( + '.copy-btn', + )[0] as HTMLButtonElement; + importCopyBtn.click(); + + await Promise.resolve(); + await Promise.resolve(); + await el.updateComplete; + + expect(importCopyBtn.textContent?.trim()).to.equal('Copied!'); + + vi.advanceTimersByTime(2000); + await el.updateComplete; + + expect(importCopyBtn.textContent?.trim()).to.equal('Copy'); + }); + + test('clipboard failure leaves button text as "Copy" without throwing', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue( + new Error('Permission denied'), + ); + + const el = await fixture(html` + + `); + + const importCopyBtn = el.shadowRoot?.querySelectorAll( + '.copy-btn', + )[0] as HTMLButtonElement; + expect(importCopyBtn.textContent?.trim()).to.equal('Copy'); + + // Should not throw + importCopyBtn.click(); + await Promise.resolve(); + await Promise.resolve(); + await el.updateComplete; + + expect(importCopyBtn.textContent?.trim()).to.equal('Copy'); + }); + }); + + describe('disconnectedCallback', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test('clears the copy timeout when element is disconnected', async () => { + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + + const container = await fixture(html` +
+ +
+ `); + + const el = container.querySelector('story-template') as StoryTemplate; + const importCopyBtn = el.shadowRoot?.querySelectorAll( + '.copy-btn', + )[0] as HTMLButtonElement; + + importCopyBtn.click(); + await Promise.resolve(); + await Promise.resolve(); + await el.updateComplete; + + expect(importCopyBtn.textContent?.trim()).to.equal('Copied!'); + + // Start fake timers, then disconnect โ€” disconnectedCallback should clearTimeout + vi.useFakeTimers(); + container.removeChild(el); + + // Advancing past the 2-second reset should produce no errors since timeout was cleared + expect(() => vi.advanceTimersByTime(3000)).not.to.throw(); + expect(container.querySelector('story-template')).to.be.null; + }); + }); +}); diff --git a/demo/story-template.ts b/demo/story-template.ts index 4973319..02932ea 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -12,7 +12,6 @@ import type { PropInputData, } from './story-components/story-prop-settings'; -import arrow from './arrow.svg'; import testTube from './test-tube.svg'; import './story-components/story-styles-settings'; @@ -38,7 +37,7 @@ export class StoryTemplate extends LitElement { @property({ type: Boolean }) labs = false; - @state() private visible = false; + @state() private detailsVisible = false; /* Stringified styles applied for the demo component */ @state() private stringifiedStyles?: string; @@ -52,14 +51,16 @@ export class StoryTemplate extends LitElement { /* Component that has been slotted into the demo, if applicable */ @state() private slottedDemoComponent?: any; + /* Tracks which copy button was last clicked, for feedback */ + @state() private copiedKey: 'import' | 'usage' | 'styling' | null = null; + private _copyTimeout?: ReturnType; + render() { return html` -

- (this.visible = !this.visible)}> - <${this.elementTag}> ${when( +
+

+ <${this.elementTag}> + ${when( this.labs, () => html``, )} - -

- ${when(this.visible, () => this.elementDemoTemplate)} - `; - } - - private get elementDemoTemplate() { - return html` -
+

Demo

-

Import

+ +
+
+ ${this.detailsTemplate} +
+
+ + `; + } + + private get detailsTemplate() { + return html` +

+ Import + +

-

Usage

+

+ Usage + +

html` -

Styling

+

+ Styling + +

`, )} - - - ${when(this.shouldShowPropertySettings, () => html`

Settings

`)} -
- +
+
+

Settings

+ ${when( + !!this.propInputData, + () => html` + + `, + )} + ${when( + !this.propInputData && !this.shouldShowPropertySettings, + () => + html`

No settings to adjust

`, + )} +
+ +
+
+
+

Styles

+ ${when( + !!this.styleInputData, + () => html` + + `, + () => + html`

No styles to adjust

`, + )} +
-
`; } + private async copyToClipboard( + text: string, + which: 'import' | 'usage' | 'styling', + ): Promise { + try { + await navigator.clipboard.writeText(text); + this.copiedKey = which; + clearTimeout(this._copyTimeout); + this._copyTimeout = setTimeout(() => (this.copiedKey = null), 2000); + } catch { + // Clipboard API unavailable (non-HTTPS or permission denied) โ€” silent fail + } + } + private get importCode(): string { if (this.elementClassName) { - return ` -import '${this.modulePath}'; -import { ${this.elementClassName} } from '${this.modulePath}'; - `; + return `import '${this.modulePath}';\nimport { ${this.elementClassName} } from '${this.modulePath}';`; } else { - return ` -import '${this.modulePath}'; - `; + return `import '${this.modulePath}';`; } } @@ -148,12 +217,7 @@ import '${this.modulePath}'; private get cssCode(): string { if (!this.stringifiedStyles) return ''; - return ` - -${this.elementTag} { - ${this.stringifiedStyles} -} - `; + return `${this.elementTag} {\n ${this.stringifiedStyles}\n}`; } private get modulePath(): string { @@ -162,7 +226,7 @@ ${this.elementTag} { : `@internetarchive/elements/${this.elementTag}/${this.elementTag}`; } - /* Toggles visibility of section depending on whether inputs have been slotted into it */ + /* Toggles visibility of section depending on whether inputs have been slotted in */ private handleSettingsSlotChange(e: Event): void { const slottedChildren = (e.target as HTMLSlotElement).assignedElements(); this.shouldShowPropertySettings = slottedChildren.length > 0; @@ -201,44 +265,145 @@ ${this.elementTag} { themeStyles, css` #container { + background: #f0f0f0; + padding: 0 10px 10px; + margin-bottom: 1rem; border: 1px solid #ccc; - padding: 0 16px 16px 16px; + } + + #details { + display: grid; + grid-template-rows: 1fr; + transition: grid-template-rows 0.2s ease; + } + + #details.collapsed { + grid-template-rows: 0fr; + } + + .details-inner { + font-size: 14px; + overflow: hidden; + min-height: 0; } h2 { - cursor: pointer; - margin-top: 8px; - margin-bottom: 8px; + font-size: 0.85rem; + font-weight: 600; + margin: 10px 0 8px; + display: flex; + align-items: center; + gap: 6px; } h3 { - margin-bottom: 8px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #666; + display: flex; + align-items: center; + gap: 5px; + margin: 8px 0 4px; + position: relative; + z-index: 1; + } + + .details-toggle { + display: inline-flex; + align-items: center; + gap: 5px; + margin-top: 6px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #595959; + cursor: pointer; + user-select: none; + border: none; + background: none; + padding: 0; + } + + .details-toggle::before { + content: 'โ–พ'; + font-size: 0.65rem; + display: inline-block; + transition: transform 0.15s; + } + + .details-toggle.collapsed::before { + transform: rotate(-90deg); + } + + .copy-btn { + background: none; + border: 1px solid #bbb; + border-radius: 3px; + padding: 1px 7px; + font-size: 0.7rem; + cursor: pointer; + color: #555; + line-height: 1.4; + } + + .copy-btn:hover { + background: #0f3e6e; + color: #fff; + border-color: #0f3e6e; + } + + .copy-btn.copied { + background: #2a7a2a; + color: #fff; + border-color: #2a7a2a; } .slot-container { background-color: var(--primary-background-color); - padding: 1em; + padding: 0.5em; } - .disclosure-arrow { - width: 12px; - height: 12px; - transform: rotate(-90deg); - transition: transform 0.2s ease-in-out; + .slot-container.hidden { + display: none; + } + + .two-col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 12px; } - .disclosure-arrow.open { - transform: rotate(0deg); + .left-col, + .right-col { + min-width: 0; + } + + .section-placeholder { + font-size: 0.78rem; + color: #767676; + margin: 4px 0; + font-style: italic; + } + + .details-inner syntax-highlighter { + display: block; + --syntax-max-height: 5.5rem; } .labs-icon { width: 20px; height: 20px; - margin-left: 4px; - filter: invert(1); vertical-align: middle; } `, ]; } + + disconnectedCallback() { + super.disconnectedCallback(); + clearTimeout(this._copyTimeout); + } } diff --git a/demo/syntax-style-light.ts b/demo/syntax-style-light.ts index 1a16432..14c24c5 100644 --- a/demo/syntax-style-light.ts +++ b/demo/syntax-style-light.ts @@ -1,6 +1,10 @@ import { css, type CSSResultGroup } from 'lit'; export const syntaxStyles: CSSResultGroup = css` + pre { + max-height: var(--syntax-max-height, none); + overflow-y: auto; + } pre code.hljs { display: block; overflow-x: auto;