From 5d165690ab1393fd66598db6787ec862453fc9d5 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 14:36:27 -0700 Subject: [PATCH 01/16] Initial restyling --- demo/app-root.ts | 87 ++++++- demo/index.css | 75 +++++- demo/story-components/story-prop-settings.ts | 1 - .../story-components/story-styles-settings.ts | 1 - demo/story-template.ts | 236 ++++++++++++++---- 5 files changed, 337 insertions(+), 63 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 181ebed..966e22b 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -6,21 +6,94 @@ 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 ELEMENT_IDS = [ + 'elem-ia-status-indicator', + 'elem-ia-combo-box', + 'elem-ia-snow', + 'elem-ia-button', +] as const; + @customElement('app-root') export class AppRoot extends LitElement { + createRenderRoot() { + return this; + } + + private _scrollHandler?: () => void; + render() { return html` -

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

+ +
+

Internet Archive Elements

-

๐Ÿš€ Production-Ready Elements

+

Production-Ready Elements

- - +
+ +
+
+ +
-

๐Ÿงช Labs Elements

+

Labs Elements

- - +
+ +
+
+ +
+
`; } + + firstUpdated() { + const links = Object.fromEntries( + ELEMENT_IDS.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + ); + + const getAnchorTops = () => + ELEMENT_IDS.map(id => { + const el = document.getElementById(id); + return el ? el.getBoundingClientRect().top + window.scrollY : null; + }); + + const updateActive = () => { + const tops = getAnchorTops(); + let current = 0; + for (let i = 0; i < tops.length; i++) { + if ((tops[i] ?? Infinity) <= window.scrollY + 40) current = i; + } + ELEMENT_IDS.forEach((id, i) => { + links[id]?.classList.toggle('active', i === current); + }); + }; + + ELEMENT_IDS.forEach((id, i) => { + links[id]?.addEventListener('click', e => { + e.preventDefault(); + const top = getAnchorTops()[i]; + if (top !== null) window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + }); + }); + + this._scrollHandler = updateActive; + window.addEventListener('scroll', updateActive, { passive: true }); + updateActive(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._scrollHandler) { + window.removeEventListener('scroll', this._scrollHandler); + } + } } diff --git a/demo/index.css b/demo/index.css index ce98d39..224d70e 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,78 @@ 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.75rem; + box-sizing: border-box; + overflow-y: auto; +} + +#ia-sidebar h2 { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #999; + margin: 1rem 0 0.35rem; +} + +#ia-sidebar h2:first-child { + margin-top: 0; +} + +#ia-sidebar a { + display: block; + font-size: 0.82rem; + font-weight: 400; + color: #444; + text-decoration: none; + padding: 3px 6px; + border-left: 2px solid transparent; + margin-bottom: 2px; +} + +#ia-sidebar a:hover { + color: #213547; + border-left-color: #aaa; +} + +#ia-sidebar a.active { + color: #0f3e6e; + border-left-color: #0f3e6e; + font-weight: 600; +} + +#ia-content { + flex: 1; + padding: 0.75rem 1rem; + padding-bottom: 100vh; + min-width: 0; +} + +.ia-anchor { + scroll-margin-top: 16px; +} + +ia-status-indicator-story, +ia-combo-box-story, +ia-snow-story, +ia-button-story { + display: block; + margin-bottom: 0.5rem; } 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.ts b/demo/story-template.ts index 4973319..8209d62 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; @@ -47,19 +46,20 @@ export class StoryTemplate extends LitElement { @state() private stringifiedProps?: string; /* Whether settings inputs have been slotted in and should be displayed */ - @state() private shouldShowPropertySettings: boolean = false; + @state() private shouldHideSettingsSlot: boolean = true; /* 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' | null = null; + 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

+ + ${when(this.detailsVisible, () => this.detailsTemplate)} + + `; + } + + private get detailsTemplate() { + return html` +
+

+ Import + +

-

Usage

+

+ Usage + +

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

Settings

`)} -
- +
+
+

Settings

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

No settings to adjust

`, + )} +
+ +
+
+
+

Styles

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

No styles to adjust

`, + )} +
`; } + private async copyToClipboard( + text: string, + which: 'import' | 'usage', + ): Promise { + await navigator.clipboard.writeText(text); + this.copiedKey = which; + setTimeout(() => (this.copiedKey = null), 2000); + } + private get importCode(): string { if (this.elementClassName) { return ` @@ -162,10 +217,10 @@ ${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; + this.shouldHideSettingsSlot = slottedChildren.length === 0; } /* Detects and stores a reference to slotted demo component */ @@ -201,41 +256,118 @@ ${this.elementTag} { themeStyles, css` #container { + background: #f0f0f0; + padding: 0 10px 10px; + margin-bottom: 1rem; border: 1px solid #ccc; - padding: 0 16px 16px 16px; + } + + #details { + font-size: 14px; } 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; + } + + .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; + } + + .left-col, + .right-col { + min-width: 0; } - .disclosure-arrow.open { - transform: rotate(0deg); + .section-placeholder { + font-size: 0.78rem; + color: #767676; + margin: 4px 0; + font-style: italic; } .labs-icon { width: 20px; height: 20px; - margin-left: 4px; - filter: invert(1); vertical-align: middle; } `, From 831ad641fe61b57cf01a11b426d5dc5c6556cf0b Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 14:42:43 -0700 Subject: [PATCH 02/16] Discover, register elements automatically --- demo/app-root.ts | 76 +++++++++++++++++++++++------------------------- 1 file changed, 36 insertions(+), 40 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 966e22b..0edf544 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -1,23 +1,27 @@ import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; +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 ELEMENT_IDS = [ - 'elem-ia-status-indicator', - 'elem-ia-combo-box', - 'elem-ia-snow', - 'elem-ia-button', -] as const; +const storyEntries = Object.keys(storyModules) + .map(path => { + const labs = path.includes('/src/labs/'); + const tag = path.split('/').at(-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; - } + createRenderRoot() { return this; } private _scrollHandler?: () => void; @@ -25,43 +29,37 @@ export class AppRoot extends LitElement { return html`

Internet Archive Elements

-

Production-Ready Elements

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

Labs Elements

- -
- -
-
- -
+ ${labsEntries.map(e => html` +
+ ${unsafeHTML(`<${e.storyTag}>`)} +
+ `)}
`; } firstUpdated() { + const allIds = ALL_ENTRIES.map(e => e.id); + const links = Object.fromEntries( - ELEMENT_IDS.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) + allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) ); const getAnchorTops = () => - ELEMENT_IDS.map(id => { + allIds.map(id => { const el = document.getElementById(id); return el ? el.getBoundingClientRect().top + window.scrollY : null; }); @@ -72,12 +70,12 @@ export class AppRoot extends LitElement { for (let i = 0; i < tops.length; i++) { if ((tops[i] ?? Infinity) <= window.scrollY + 40) current = i; } - ELEMENT_IDS.forEach((id, i) => { + allIds.forEach((id, i) => { links[id]?.classList.toggle('active', i === current); }); }; - ELEMENT_IDS.forEach((id, i) => { + allIds.forEach((id, i) => { links[id]?.addEventListener('click', e => { e.preventDefault(); const top = getAnchorTops()[i]; @@ -92,8 +90,6 @@ export class AppRoot extends LitElement { disconnectedCallback() { super.disconnectedCallback(); - if (this._scrollHandler) { - window.removeEventListener('scroll', this._scrollHandler); - } + if (this._scrollHandler) window.removeEventListener('scroll', this._scrollHandler); } } From 6b357594072fb5ec6095769a3abdd32332da7096 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 15:12:36 -0700 Subject: [PATCH 03/16] ES2021 fix --- demo/app-root.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 0edf544..81d0d2f 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -10,7 +10,8 @@ const storyModules = import.meta.glob( const storyEntries = Object.keys(storyModules) .map(path => { const labs = path.includes('/src/labs/'); - const tag = path.split('/').at(-2)!; + 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)); @@ -76,7 +77,7 @@ export class AppRoot extends LitElement { }; allIds.forEach((id, i) => { - links[id]?.addEventListener('click', e => { + links[id]?.addEventListener('click', (e: Event) => { e.preventDefault(); const top = getAnchorTops()[i]; if (top !== null) window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); From f7320c46153479cd384558e4d204b5d9c49e3f32 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 15:28:39 -0700 Subject: [PATCH 04/16] Removed hard-coded element names from CSS --- demo/index.css | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/demo/index.css b/demo/index.css index 224d70e..4caae42 100644 --- a/demo/index.css +++ b/demo/index.css @@ -92,10 +92,7 @@ app-root { scroll-margin-top: 16px; } -ia-status-indicator-story, -ia-combo-box-story, -ia-snow-story, -ia-button-story { +.ia-anchor > * { display: block; margin-bottom: 0.5rem; } From 34768340e9b23ba2a03dc803475b701fdb0909d7 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 15:43:04 -0700 Subject: [PATCH 05/16] Placeholder suppression cleanup --- demo/story-template.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index 8209d62..60610f9 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -46,7 +46,7 @@ export class StoryTemplate extends LitElement { @state() private stringifiedProps?: string; /* Whether settings inputs have been slotted in and should be displayed */ - @state() private shouldHideSettingsSlot: boolean = true; + @state() private shouldShowPropertySettings: boolean = false; /* Component that has been slotted into the demo, if applicable */ @state() private slottedDemoComponent?: any; @@ -142,11 +142,14 @@ export class StoryTemplate extends LitElement { @propsApplied=${this.handlePropsApplied} > `, + )} + ${when( + !this.propInputData && !this.shouldShowPropertySettings, () => html`

No settings to adjust

`, )}
@@ -220,7 +223,7 @@ ${this.elementTag} { /* 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.shouldHideSettingsSlot = slottedChildren.length === 0; + this.shouldShowPropertySettings = slottedChildren.length > 0; } /* Detects and stores a reference to slotted demo component */ From e974ffaecabc341a75525b3ae73f50dca7c87066 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 16:55:35 -0700 Subject: [PATCH 06/16] sidebar styling adjustments --- demo/index.css | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/demo/index.css b/demo/index.css index 4caae42..be8069d 100644 --- a/demo/index.css +++ b/demo/index.css @@ -41,7 +41,7 @@ app-root { top: 0; height: 100vh; border-right: 1px solid #ddd; - padding: 1rem 0.75rem; + padding: 1rem 0; box-sizing: border-box; overflow-y: auto; } @@ -50,13 +50,10 @@ app-root { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.06em; - color: #999; - margin: 1rem 0 0.35rem; -} - -#ia-sidebar h2:first-child { - margin-top: 0; + letter-spacing: 0.08em; + color: #767676; + padding: 0 1rem; + margin: 0 0 0.5rem; } #ia-sidebar a { @@ -65,19 +62,23 @@ app-root { font-weight: 400; color: #444; text-decoration: none; - padding: 3px 6px; - border-left: 2px solid transparent; - margin-bottom: 2px; + 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; - border-left-color: #aaa; } #ia-sidebar a.active { - color: #0f3e6e; - border-left-color: #0f3e6e; + color: #194880; + border-left-color: #194880; + background: #f0f4f9; font-weight: 600; } From 4336646654eefee5d39cbe71fcd3f8376349f248 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 12 Mar 2026 17:15:45 -0700 Subject: [PATCH 07/16] Added transition for collapsing details --- demo/story-template.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index 60610f9..543cebd 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -83,14 +83,17 @@ export class StoryTemplate extends LitElement { > Import, Usage & Settings - ${when(this.detailsVisible, () => this.detailsTemplate)} +
+
+ ${this.detailsTemplate} +
+
`; } private get detailsTemplate() { return html` -

Import

-
`; } @@ -266,7 +268,19 @@ ${this.elementTag} { } #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 { @@ -288,6 +302,12 @@ ${this.elementTag} { align-items: center; gap: 5px; margin: 8px 0 4px; + position: relative; + z-index: 1; + } + + h2 code { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; } .details-toggle { From 118b0158e1aa578aac7e1ca3019abb97aca87378 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Fri, 13 Mar 2026 15:19:38 -0700 Subject: [PATCH 08/16] Shrink h1 h2 --- demo/index.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/demo/index.css b/demo/index.css index be8069d..ad8f43f 100644 --- a/demo/index.css +++ b/demo/index.css @@ -97,3 +97,16 @@ app-root { 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; +} From b1dddc3324837b1d829ac547dce87f2a86e9f4e6 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Fri, 13 Mar 2026 15:27:45 -0700 Subject: [PATCH 09/16] Add copy button to styling section --- demo/story-template.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index 543cebd..8327f65 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -52,7 +52,7 @@ export class StoryTemplate extends LitElement { @state() private slottedDemoComponent?: any; /* Tracks which copy button was last clicked, for feedback */ - @state() private copiedKey: 'import' | 'usage' | null = null; + @state() private copiedKey: 'import' | 'usage' | 'styling' | null = null; render() { return html` @@ -127,7 +127,15 @@ export class StoryTemplate extends LitElement { ${when( this.cssCode, () => html` -

Styling

+

+ Styling + +

{ await navigator.clipboard.writeText(text); this.copiedKey = which; From e493e09b7f3ffbb3058f6187a4ab992434f3d1a8 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Wed, 18 Mar 2026 15:06:57 -0700 Subject: [PATCH 10/16] Cap syntax-highlighter height + scrolling --- demo/story-template.ts | 5 +++++ demo/syntax-style-light.ts | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/demo/story-template.ts b/demo/story-template.ts index 8327f65..41ff101 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -396,6 +396,11 @@ ${this.elementTag} { font-style: italic; } + .details-inner syntax-highlighter { + display: block; + --syntax-max-height: 5.5rem; + } + .labs-icon { width: 20px; height: 20px; 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; From 64648eb20369f03c083bf2a359363e06077e5612 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Wed, 18 Mar 2026 15:21:44 -0700 Subject: [PATCH 11/16] Copy to clipboard fixes - add error handling - remove excess whitespace --- demo/story-template.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/demo/story-template.ts b/demo/story-template.ts index 41ff101..bb91495 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -188,21 +188,20 @@ export class StoryTemplate extends LitElement { text: string, which: 'import' | 'usage' | 'styling', ): Promise { - await navigator.clipboard.writeText(text); - this.copiedKey = which; - setTimeout(() => (this.copiedKey = null), 2000); + try { + await navigator.clipboard.writeText(text); + this.copiedKey = which; + 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}';`; } } @@ -216,12 +215,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 { From 89b54d041cd82819e9bd5ca35f33ef885e068cf6 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Wed, 18 Mar 2026 15:28:01 -0700 Subject: [PATCH 12/16] Switch to IntersectionObserver --- demo/app-root.ts | 51 +++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 81d0d2f..8c9b46d 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -24,7 +24,7 @@ const ALL_ENTRIES = [...productionEntries, ...labsEntries]; export class AppRoot extends LitElement { createRenderRoot() { return this; } - private _scrollHandler?: () => void; + private _observer?: IntersectionObserver; render() { return html` @@ -59,38 +59,41 @@ export class AppRoot extends LitElement { allIds.map(id => [id, this.querySelector(`#ia-sidebar a[href="#${id}"]`)]) ); - const getAnchorTops = () => - allIds.map(id => { - const el = document.getElementById(id); - return el ? el.getBoundingClientRect().top + window.scrollY : null; - }); + const visible = new Set(); - const updateActive = () => { - const tops = getAnchorTops(); - let current = 0; - for (let i = 0; i < tops.length; i++) { - if ((tops[i] ?? Infinity) <= window.scrollY + 40) current = i; - } - allIds.forEach((id, i) => { - links[id]?.classList.toggle('active', i === current); - }); - }; + // 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, i) => { + 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 top = getAnchorTops()[i]; - if (top !== null) window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + const el = document.getElementById(id); + if (el) { + const top = el.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); + } }); }); - - this._scrollHandler = updateActive; - window.addEventListener('scroll', updateActive, { passive: true }); - updateActive(); } disconnectedCallback() { super.disconnectedCallback(); - if (this._scrollHandler) window.removeEventListener('scroll', this._scrollHandler); + this._observer?.disconnect(); } } From 24ab7d4c6d6a1f1446284656e3b5dcb6c13d1552 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 19 Mar 2026 13:26:29 -0700 Subject: [PATCH 13/16] Cleanup for setTimeout --- demo/app-root.ts | 2 ++ demo/story-template.ts | 13 ++++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 8c9b46d..5d018ab 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -1,5 +1,7 @@ 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'; const storyModules = import.meta.glob( diff --git a/demo/story-template.ts b/demo/story-template.ts index bb91495..02932ea 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -53,6 +53,7 @@ export class StoryTemplate extends LitElement { /* Tracks which copy button was last clicked, for feedback */ @state() private copiedKey: 'import' | 'usage' | 'styling' | null = null; + private _copyTimeout?: ReturnType; render() { return html` @@ -191,7 +192,8 @@ export class StoryTemplate extends LitElement { try { await navigator.clipboard.writeText(text); this.copiedKey = which; - setTimeout(() => (this.copiedKey = null), 2000); + clearTimeout(this._copyTimeout); + this._copyTimeout = setTimeout(() => (this.copiedKey = null), 2000); } catch { // Clipboard API unavailable (non-HTTPS or permission denied) โ€” silent fail } @@ -308,10 +310,6 @@ export class StoryTemplate extends LitElement { z-index: 1; } - h2 code { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - } - .details-toggle { display: inline-flex; align-items: center; @@ -403,4 +401,9 @@ export class StoryTemplate extends LitElement { `, ]; } + + disconnectedCallback() { + super.disconnectedCallback(); + clearTimeout(this._copyTimeout); + } } From 3ba002fd2d55de852cf6c912e9b037629a6b236b Mon Sep 17 00:00:00 2001 From: IA Jim Date: Thu, 19 Mar 2026 14:14:02 -0700 Subject: [PATCH 14/16] tests --- demo/story-template.test.ts | 269 ++++++++++++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 demo/story-template.test.ts 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; + }); + }); +}); From f51818a2db943eb94c895e706cc24d86344f0e92 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Mon, 30 Mar 2026 15:04:49 -0700 Subject: [PATCH 15/16] CR suggestion fixes - base tag name on file name - click listener cleanup - adds semantic 'expanded' class string - console warns on clipboard failures --- demo/app-root.ts | 11 +++++++---- demo/index.css | 2 +- demo/story-template.ts | 8 ++++---- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/demo/app-root.ts b/demo/app-root.ts index 5d018ab..89f9ab1 100644 --- a/demo/app-root.ts +++ b/demo/app-root.ts @@ -13,20 +13,22 @@ const storyEntries = Object.keys(storyModules) .map(path => { const labs = path.includes('/src/labs/'); const parts = path.split('/'); - const tag = parts[parts.length - 2]; + const filename = parts[parts.length - 1]; // e.g. "ia-button-story.ts" + const tag = filename.replace(/-story\.ts$/, ''); 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]; +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; + private _abortController = new AbortController(); render() { return html` @@ -90,12 +92,13 @@ export class AppRoot extends LitElement { const top = el.getBoundingClientRect().top + window.scrollY; window.scrollTo({ top: Math.max(0, top - 16), behavior: 'smooth' }); } - }); + }, { signal: this._abortController.signal }); }); } disconnectedCallback() { super.disconnectedCallback(); this._observer?.disconnect(); + this._abortController.abort(); } } diff --git a/demo/index.css b/demo/index.css index ad8f43f..560264d 100644 --- a/demo/index.css +++ b/demo/index.css @@ -85,7 +85,7 @@ app-root { #ia-content { flex: 1; padding: 0.75rem 1rem; - padding-bottom: 100vh; + padding-bottom: 100vh; /* lets scroll-spy activate on the last element */ min-width: 0; } diff --git a/demo/story-template.ts b/demo/story-template.ts index 02932ea..fc3a16a 100644 --- a/demo/story-template.ts +++ b/demo/story-template.ts @@ -79,12 +79,12 @@ export class StoryTemplate extends LitElement { > -
+
${this.detailsTemplate}
@@ -194,8 +194,8 @@ export class StoryTemplate extends LitElement { this.copiedKey = which; clearTimeout(this._copyTimeout); this._copyTimeout = setTimeout(() => (this.copiedKey = null), 2000); - } catch { - // Clipboard API unavailable (non-HTTPS or permission denied) โ€” silent fail + } catch (e) { + console.warn('Clipboard write failed:', e); } } From b6355b2018f03f907ad9595fcadcf0f3994678a9 Mon Sep 17 00:00:00 2001 From: IA Jim Date: Mon, 30 Mar 2026 15:14:22 -0700 Subject: [PATCH 16/16] Updated tests with 'expanded' class --- demo/story-template.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/demo/story-template.test.ts b/demo/story-template.test.ts index 4aa2cd8..e5087d5 100644 --- a/demo/story-template.test.ts +++ b/demo/story-template.test.ts @@ -113,6 +113,7 @@ describe('StoryTemplate', () => { const details = el.shadowRoot?.querySelector('#details'); expect(details?.classList.contains('collapsed')).to.be.true; + expect(details?.classList.contains('expanded')).to.be.false; }); test('clicking .details-toggle removes collapsed class from #details', async () => { @@ -130,6 +131,7 @@ describe('StoryTemplate', () => { const details = el.shadowRoot?.querySelector('#details'); expect(details?.classList.contains('collapsed')).to.be.false; + expect(details?.classList.contains('expanded')).to.be.true; }); test('clicking .details-toggle a second time restores collapsed class', async () => { @@ -145,10 +147,12 @@ describe('StoryTemplate', () => { toggleBtn.click(); await el.updateComplete; expect(details?.classList.contains('collapsed')).to.be.false; + expect(details?.classList.contains('expanded')).to.be.true; toggleBtn.click(); await el.updateComplete; expect(details?.classList.contains('collapsed')).to.be.true; + expect(details?.classList.contains('expanded')).to.be.false; }); });