From 7863508b8099f90f22855ffd141c3f552e76e7ae Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 15:59:45 -0700 Subject: [PATCH 1/5] WEBDEV-9050: Migrate the donation banner thermometer into elements Moves banner-thermometer out of iaux-donation-form as ia-donation-thermometer, with a story and tests on Vitest. Only the petabox banner uses it so it gets its own directory rather than living under the donation form. The shared-resize-observer dep is gone in favor of a plain ResizeObserver, and the label is re-observed when it swaps sides (the old code stopped measuring it after the first swap). CSS variables move to --ia-donation-thermometer-*, the pill shape comes from a large border radius instead of measuring the host height, and the progress bar gets an accessible name. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- .../element-names.test.ts | 19 ++ .../ia-donation-thermometer-story.ts | 147 +++++++++ .../ia-donation-thermometer.test.ts | 267 +++++++++++++++ .../ia-donation-thermometer.ts | 311 ++++++++++++++++++ src/elements/index.ts | 1 + 5 files changed, 745 insertions(+) create mode 100644 src/elements/ia-donation-thermometer/element-names.test.ts create mode 100644 src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts create mode 100644 src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts create mode 100644 src/elements/ia-donation-thermometer/ia-donation-thermometer.ts diff --git a/src/elements/ia-donation-thermometer/element-names.test.ts b/src/elements/ia-donation-thermometer/element-names.test.ts new file mode 100644 index 00000000..2d40fe8e --- /dev/null +++ b/src/elements/ia-donation-thermometer/element-names.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; + +import './ia-donation-thermometer'; + +/** + * Custom elements share one global registry. The + * `@internetarchive/donation-banner-thermometer` package registers + * `donation-banner-thermometer` verbatim, and the petabox banner keeps loading + * it during the migration, so this element must not claim that name. + */ +describe('donation thermometer element names', () => { + test('registers the thermometer under its own name', () => { + expect(customElements.get('ia-donation-thermometer')).to.exist; + }); + + test('leaves donation-banner-thermometer free for the old package', () => { + expect(customElements.get('donation-banner-thermometer')).to.not.exist; + }); +}); diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts new file mode 100644 index 00000000..04fc721a --- /dev/null +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts @@ -0,0 +1,147 @@ +import { html, LitElement } from 'lit'; +import { customElement } 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 { IADonationThermometer } from './ia-donation-thermometer'; + +import './ia-donation-thermometer'; +import '@demo/story-template'; + +const styleInputSettings: StyleInputSettings[] = [ + { + label: 'Height', + cssVariable: '--ia-donation-thermometer-height', + defaultValue: 20, + inputType: 'range', + min: 10, + max: 60, + unit: 'px', + }, + { + label: 'Border', + cssVariable: '--ia-donation-thermometer-border', + defaultValue: '1px solid #23765d', + inputType: 'text', + presets: [{ label: 'None', value: '0', note: 'donation banner' }], + presetsInline: true, + }, + { + label: 'Border radius', + cssVariable: '--ia-donation-thermometer-border-radius', + defaultValue: '9999px', + inputType: 'text', + presets: [{ label: 'Square', value: '0' }], + presetsInline: true, + }, + { + label: 'Goal padding', + cssVariable: '--ia-donation-thermometer-goal-padding', + defaultValue: '0 10px', + inputType: 'text', + presets: [{ label: 'Banner', value: '0 0.5rem', note: 'donation banner' }], + presetsInline: true, + }, + { + section: 'Color', + label: 'Fill', + cssVariable: '--ia-donation-thermometer-fill-color', + defaultValue: '#23765d', + inputType: 'color', + }, + { + section: 'Color', + label: 'Track', + cssVariable: '--ia-donation-thermometer-track-color', + defaultValue: '#b8f5e2', + inputType: 'color', + }, + { + section: 'Color', + label: 'Value on fill', + cssVariable: '--ia-donation-thermometer-value-on-fill-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + section: 'Color', + label: 'Value on track', + cssVariable: '--ia-donation-thermometer-value-on-track-color', + defaultValue: '#23765d', + inputType: 'color', + }, + { + section: 'Color', + label: 'Goal text', + cssVariable: '--ia-theme-primary-text-color', + defaultValue: '#2c2c2c', + inputType: 'color', + }, +]; + +const propInputSettings: PropInputSettings[] = [ + { + label: 'Current amount', + propertyName: 'currentAmount', + defaultValue: 2_350_000, + inputType: 'number', + }, + { + label: 'Goal amount', + propertyName: 'goalAmount', + defaultValue: 6_500_000, + inputType: 'number', + }, + { + label: 'Show current amount', + propertyName: 'currentAmountMode', + defaultValue: 'on', + inputType: 'radio', + radioOptions: ['on', 'off'], + }, + { + label: 'Goal text', + propertyName: 'goalMessageMode', + defaultValue: 'amount', + inputType: 'radio', + radioOptions: ['amount', 'message', 'off'], + }, + { + label: 'Accessible label', + propertyName: 'label', + defaultValue: 'Donation progress', + }, + { + section: 'Goal messages', + label: 'Near goal', + propertyName: 'goalNearMessage', + defaultValue: 'We’ve almost reached our goal!', + }, + { + section: 'Goal messages', + label: 'Goal reached', + propertyName: 'goalReachedMessage', + defaultValue: "We've reached our goal!", + }, +]; + +@customElement('ia-donation-thermometer-story') +export class IADonationThermometerStory extends LitElement { + render() { + return html` + + + + `; + } +} diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts new file mode 100644 index 00000000..49c1d63a --- /dev/null +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts @@ -0,0 +1,267 @@ +import { fixture } from '@open-wc/testing-helpers'; +import { describe, expect, test } from 'vitest'; +import { html, type TemplateResult } from 'lit'; + +import type { IADonationThermometer } from './ia-donation-thermometer'; +import './ia-donation-thermometer'; + +/** + * ResizeObserver reports after layout, before the next paint. Two frames give + * it time to fire and the element time to re-render on the new widths. + */ +async function settleLayout(el: IADonationThermometer): Promise { + await el.updateComplete; + await new Promise((resolve) => requestAnimationFrame(resolve)); + await new Promise((resolve) => requestAnimationFrame(resolve)); + await el.updateComplete; +} + +/** Renders the thermometer at a known width so the fill sizes are predictable. */ +async function sizedFixture( + template: TemplateResult, +): Promise { + const wrapper = await fixture( + html`
${template}
`, + ); + const el = wrapper.querySelector( + 'ia-donation-thermometer', + )!; + await settleLayout(el); + return el; +} + +function background(el: IADonationThermometer): HTMLDivElement { + return el.shadowRoot!.querySelector('.thermometer-background')!; +} + +function fill(el: IADonationThermometer): HTMLDivElement { + return el.shadowRoot!.querySelector('.thermometer-fill')!; +} + +function goalMessage(el: IADonationThermometer): HTMLDivElement | null { + return el.shadowRoot!.querySelector('.donate-goal'); +} + +function currentValue(el: IADonationThermometer): HTMLDivElement | null { + return el.shadowRoot!.querySelector('.thermometer-value'); +} + +describe('IADonationThermometer', () => { + test('has a background track and fill layer', async () => { + const el = await fixture( + html``, + ); + + expect(background(el)).to.exist; + expect(fill(el)).to.exist; + }); + + test('exposes the amounts as a progressbar', async () => { + const el = await fixture( + html``, + ); + + const bar = el.shadowRoot!.querySelector('[role="progressbar"]')!; + expect(bar.getAttribute('aria-label')).to.equal('Donation progress'); + expect(bar.getAttribute('aria-valuemin')).to.equal('0'); + expect(bar.getAttribute('aria-valuemax')).to.equal('1000000'); + expect(bar.getAttribute('aria-valuenow')).to.equal('500000'); + expect(bar.getAttribute('aria-valuetext')).to.equal('$0.5MM'); + }); + + test('lets a consumer name the progress bar', async () => { + const el = await fixture( + html``, + ); + + const bar = el.shadowRoot!.querySelector('[role="progressbar"]')!; + expect(bar.getAttribute('aria-label')).to.equal('End of year goal'); + }); + + test('defaults to showing the goal value at the end of the thermometer and the current value', async () => { + const el = await fixture( + html``, + ); + + expect(goalMessage(el)?.textContent?.trim()).to.equal('$1MM goal'); + expect(currentValue(el)?.textContent?.trim()).to.equal('$0.5MM'); + }); + + test('sizes the fill to the fraction of the goal raised', async () => { + const el = await fixture( + html``, + ); + + expect(fill(el).style.width).to.equal('25%'); + }); + + test('caps the fill at 100% once the goal is passed', async () => { + const el = await fixture( + html``, + ); + + expect(fill(el).style.width).to.equal('100%'); + }); + + test('can hide the goal', async () => { + const el = await fixture( + html``, + ); + + expect(goalMessage(el)).to.be.null; + }); + + test('can hide the current amount', async () => { + const el = await fixture( + html``, + ); + + expect(currentValue(el)).to.be.null; + }); + + test('can display goal met message', async () => { + const el = await fixture( + html``, + ); + + expect(goalMessage(el)?.textContent?.trim()).to.equal('GOAL MET'); + }); + + test('can display goal near message', async () => { + const el = await fixture( + html``, + ); + + expect(goalMessage(el)?.textContent?.trim()).to.equal('GOAL NEAR'); + }); + + test.each([ + [0, '$0'], + [500_000, '$0.5MM'], + [1_250_000, '$1.3MM'], + [9_950_000, '$10MM'], + [35_000_000, '$35MM'], + [35_600_000, '$36MM'], + ])('formats %i as %s', async (amount, display) => { + const el = await fixture( + html``, + ); + + expect(currentValue(el)?.textContent?.trim()).to.equal(display); + }); + + test('shows the current value on the right if the fill is too skinny', async () => { + const el = await sizedFixture( + html``, + ); + + expect(background(el).classList.contains('value-right')).to.be.true; + expect(currentValue(el)?.parentElement).to.equal(background(el)); + }); + + test('shows the current value on the left if there is room', async () => { + const el = await sizedFixture( + html``, + ); + + expect(background(el).classList.contains('value-left')).to.be.true; + expect(currentValue(el)?.parentElement).to.equal(fill(el)); + }); + + test('moves the value across as the fill grows and shrinks', async () => { + const el = await sizedFixture( + html``, + ); + expect(background(el).classList.contains('value-right')).to.be.true; + + el.currentAmount = 750_000; + await settleLayout(el); + expect(background(el).classList.contains('value-left')).to.be.true; + + el.currentAmount = 1_000; + await settleLayout(el); + expect(background(el).classList.contains('value-right')).to.be.true; + }); + + test('keeps measuring the value label after it changes sides', async () => { + const el = await sizedFixture( + html``, + ); + expect(background(el).classList.contains('value-right')).to.be.true; + + // A fill wide enough to hold the label moves it inside + el.currentAmount = 750_000; + await settleLayout(el); + expect(background(el).classList.contains('value-left')).to.be.true; + + // Once inside, a label that outgrows the fill has to move back out. The + // fill stays put while a much larger font makes the label wider than it. + el.style.fontSize = '80px'; + await settleLayout(el); + expect(background(el).classList.contains('value-right')).to.be.true; + }); + + test('keeps tracking sizes after being moved in the page', async () => { + const el = await sizedFixture( + html``, + ); + const parent = el.parentElement!; + + el.remove(); + parent.append(el); + el.currentAmount = 750_000; + await settleLayout(el); + + expect(background(el).classList.contains('value-left')).to.be.true; + }); +}); diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts new file mode 100644 index 00000000..328ff782 --- /dev/null +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts @@ -0,0 +1,311 @@ +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +import { customElement, property, query, state } from 'lit/decorators.js'; +import { msg, str } from '@lit/localize'; + +import themeStyles from '@src/themes/theme-styles'; + +/** What the text beside the bar says: the goal amount, a message, or nothing. */ +export type GoalMessageMode = 'off' | 'amount' | 'message'; + +/** Whether the current amount is printed on the bar. */ +export type CurrentAmountMode = 'on' | 'off'; + +/** + * A horizontal progress bar for a fundraising goal, as shown in the + * archive.org donation banner. The fill grows with `currentAmount`, the amount + * raised is printed on the fill when it fits and beside it when it doesn't, + * and the goal (or a goal message) sits to the right of the bar. + */ +@customElement('ia-donation-thermometer') +export class IADonationThermometer extends LitElement { + @property({ type: String }) goalMessageMode: GoalMessageMode = 'amount'; + + @property({ type: String }) goalNearMessage = msg( + 'We’ve almost reached our goal!', + ); + + @property({ type: String }) goalReachedMessage = msg( + "We've reached our goal!", + ); + + /** The accessible name of the progress bar, for screen readers. */ + @property({ type: String }) label = msg('Donation progress'); + + @property({ type: Number }) goalAmount = 7_500_000; + + @property({ type: String }) currentAmountMode: CurrentAmountMode = 'on'; + + @property({ type: Number }) currentAmount = 0; + + @query('.thermometer-value') private thermometerValue?: HTMLDivElement; + + @query('.thermometer-fill') private thermometerFill!: HTMLDivElement; + + @state() private thermometerValueWidth = 0; + + @state() private thermometerFillWidth = 0; + + /** + * Watches the fill and the value label so the label can move to whichever + * side of the fill has room for it. + */ + private resizeObserver?: ResizeObserver; + + /** + * The value label renders in a different spot on each side of the fill, so + * the observed element is tracked to re-observe the replacement. + */ + private observedValueElement: Element | null = null; + + render(): TemplateResult { + return html` +
+
+
+
+
+ ${this.thermometerValuePosition === 'value-left' + ? this.thermometerValueTemplate + : nothing} +
+ ${this.thermometerValuePosition === 'value-right' + ? this.thermometerValueTemplate + : nothing} +
+
+ ${this.goalMessageMode !== 'off' + ? html`` + : nothing} +
+
+ `; + } + + private get thermometerValueTemplate(): TemplateResult | typeof nothing { + return this.currentAmountMode === 'off' + ? nothing + : html` +
${this.currentAmountDisplayValue}
+ `; + } + + /** + * Which side of the fill the value label sits on. It goes inside the fill + * when the fill is wider than the label plus a little breathing room, and + * outside to the right otherwise. + */ + private get thermometerValuePosition(): 'value-left' | 'value-right' { + const buffer = 10; + return this.thermometerValueWidth + buffer < this.thermometerFillWidth + ? 'value-left' + : 'value-right'; + } + + connectedCallback(): void { + super.connectedCallback(); + this.resizeObserver = new ResizeObserver((entries) => + this.handleResize(entries), + ); + // On a re-connect the fill already exists; on first connect it doesn't + // until the first render, and `updated` picks it up then. + this.observeParts(); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.observedValueElement = null; + } + + updated(): void { + this.observeParts(); + } + + /** Observes the fill once and follows the value label as it moves. */ + private observeParts(): void { + const observer = this.resizeObserver; + if (!observer) return; + + if (this.thermometerFill) observer.observe(this.thermometerFill); + + const valueElement = this.thermometerValue ?? null; + if (valueElement === this.observedValueElement) return; + + if (this.observedValueElement) + observer.unobserve(this.observedValueElement); + if (valueElement) observer.observe(valueElement); + this.observedValueElement = valueElement; + } + + private handleResize(entries: ResizeObserverEntry[]): void { + for (const entry of entries) { + const width = + entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + if (entry.target === this.thermometerFill) { + this.thermometerFillWidth = width; + } else if (entry.target === this.observedValueElement) { + this.thermometerValueWidth = width; + } + } + } + + private get goalMessage(): string { + return this.currentAmount >= this.goalAmount + ? this.goalReachedMessage + : this.goalNearMessage; + } + + private get currentAmountDisplayValue(): string { + return this.formatNumber(this.currentAmount); + } + + private get goalAmountDisplayValue(): string { + return this.formatNumber(this.goalAmount); + } + + /** + * Formats a dollar amount in millions, e.g. `$0.5MM` or `$35MM`. Amounts + * under ten million keep one decimal place, larger ones round to a whole + * number. + */ + private formatNumber(number: number): string { + if (number === 0) return '$0'; + const suffix = 'MM'; + const divisor = 1_000_000; + const result = number / divisor; + const roundToOne = result < 10; + const rounded = roundToOne + ? Math.round((result + Number.EPSILON) * 10) / 10 + : Math.round(result); + return `$${rounded}${suffix}`; + } + + private get currentGoalMessage(): string { + switch (this.goalMessageMode) { + case 'amount': + return msg(str`${this.goalAmountDisplayValue} goal`); + case 'message': + return this.goalMessage; + case 'off': + return ''; + } + } + + private get percentComplete(): number { + return Math.min((this.currentAmount / this.goalAmount) * 100, 100); + } + + static get styles(): CSSResultGroup { + return [ + themeStyles, + css` + :host { + --thermometer-height--: var(--ia-donation-thermometer-height, 20px); + --fill-color--: var(--ia-donation-thermometer-fill-color, #23765d); + --track-color--: var(--ia-donation-thermometer-track-color, #b8f5e2); + --border--: var( + --ia-donation-thermometer-border, + 1px solid var(--fill-color--) + ); + /* Large enough to round any height into a pill */ + --border-radius--: var( + --ia-donation-thermometer-border-radius, + 9999px + ); + --value-on-fill-color--: var( + --ia-donation-thermometer-value-on-fill-color, + var(--true-white) + ); + --value-on-track-color--: var( + --ia-donation-thermometer-value-on-track-color, + var(--fill-color--) + ); + --goal-color--: var(--primary-text-color); + --goal-padding--: var(--ia-donation-thermometer-goal-padding, 0 10px); + + display: block; + height: var(--thermometer-height--); + } + + .container { + height: 100%; + } + + .thermometer-message-container { + height: 100%; + display: flex; + align-items: center; + } + + .thermometer-container { + height: 100%; + flex: 1; + } + + .thermometer-background { + background-color: var(--track-color--); + padding: 0; + height: 100%; + border-radius: var(--border-radius--); + border: var(--border--); + overflow: hidden; + display: flex; + align-items: center; + } + + .thermometer-fill { + background-color: var(--fill-color--); + text-align: right; + height: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + } + + .thermometer-value { + font-weight: bold; + white-space: nowrap; + } + + .value-left .thermometer-value { + color: var(--value-on-fill-color--); + padding: 0 0.5rem 0 1rem; + } + + .value-right .thermometer-value { + color: var(--value-on-track-color--); + padding: 0 1rem 0 0.5rem; + } + + .donate-goal { + text-align: left; + padding: var(--goal-padding--); + text-transform: uppercase; + font-weight: bold; + color: var(--goal-color--); + } + `, + ]; + } +} diff --git a/src/elements/index.ts b/src/elements/index.ts index 711168e1..eef9b045 100644 --- a/src/elements/index.ts +++ b/src/elements/index.ts @@ -2,6 +2,7 @@ export * from './ia-audio-element/ia-audio-element'; export * from './ia-audio-element/models'; export * from './ia-button/ia-button'; export * from './ia-combo-box/ia-combo-box'; +export * from './ia-donation-thermometer/ia-donation-thermometer'; export * from './ia-dropdown-search-bar/ia-dropdown-search-bar'; export * from './ia-expandable-search-bar/ia-expandable-search-bar'; export * from './ia-expandable-search-bar/ia-quick-search'; From 415be0bf26660a8895a363dfb1527893cce8bc78 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 16:13:24 -0700 Subject: [PATCH 2/5] WEBDEV-9050: Use the same apostrophe in both default goal messages The near-goal message used a curly apostrophe and the reached message a straight one. Both are curly now. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- .../ia-donation-thermometer/ia-donation-thermometer-story.ts | 2 +- src/elements/ia-donation-thermometer/ia-donation-thermometer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts index 04fc721a..c52d56e0 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts @@ -121,7 +121,7 @@ const propInputSettings: PropInputSettings[] = [ section: 'Goal messages', label: 'Goal reached', propertyName: 'goalReachedMessage', - defaultValue: "We've reached our goal!", + defaultValue: 'We’ve reached our goal!', }, ]; diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts index 328ff782..78469d0c 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts @@ -32,7 +32,7 @@ export class IADonationThermometer extends LitElement { ); @property({ type: String }) goalReachedMessage = msg( - "We've reached our goal!", + 'We’ve reached our goal!', ); /** The accessible name of the progress bar, for screen readers. */ From c11643040a208130678750444683ac564037b7ff Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Mon, 14 Sep 2026 16:33:59 -0700 Subject: [PATCH 3/5] WEBDEV-9050: Prefix the thermometer's private CSS variables Private variables on :host inherit into whatever is nested or slotted, so the README asks for a component prefix. --fill-color-- and friends are now --donation-thermometer-fill-color-- and so on. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QnX6VHo3EJRzcKfhzBChCf --- .../ia-donation-thermometer.ts | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts index 78469d0c..11b8b9d3 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts @@ -221,31 +221,43 @@ export class IADonationThermometer extends LitElement { themeStyles, css` :host { - --thermometer-height--: var(--ia-donation-thermometer-height, 20px); - --fill-color--: var(--ia-donation-thermometer-fill-color, #23765d); - --track-color--: var(--ia-donation-thermometer-track-color, #b8f5e2); - --border--: var( + --donation-thermometer-height--: var( + --ia-donation-thermometer-height, + 20px + ); + --donation-thermometer-fill-color--: var( + --ia-donation-thermometer-fill-color, + #23765d + ); + --donation-thermometer-track-color--: var( + --ia-donation-thermometer-track-color, + #b8f5e2 + ); + --donation-thermometer-border--: var( --ia-donation-thermometer-border, - 1px solid var(--fill-color--) + 1px solid var(--donation-thermometer-fill-color--) ); /* Large enough to round any height into a pill */ - --border-radius--: var( + --donation-thermometer-border-radius--: var( --ia-donation-thermometer-border-radius, 9999px ); - --value-on-fill-color--: var( + --donation-thermometer-value-on-fill-color--: var( --ia-donation-thermometer-value-on-fill-color, var(--true-white) ); - --value-on-track-color--: var( + --donation-thermometer-value-on-track-color--: var( --ia-donation-thermometer-value-on-track-color, - var(--fill-color--) + var(--donation-thermometer-fill-color--) + ); + --donation-thermometer-goal-color--: var(--primary-text-color); + --donation-thermometer-goal-padding--: var( + --ia-donation-thermometer-goal-padding, + 0 10px ); - --goal-color--: var(--primary-text-color); - --goal-padding--: var(--ia-donation-thermometer-goal-padding, 0 10px); display: block; - height: var(--thermometer-height--); + height: var(--donation-thermometer-height--); } .container { @@ -264,18 +276,18 @@ export class IADonationThermometer extends LitElement { } .thermometer-background { - background-color: var(--track-color--); + background-color: var(--donation-thermometer-track-color--); padding: 0; height: 100%; - border-radius: var(--border-radius--); - border: var(--border--); + border-radius: var(--donation-thermometer-border-radius--); + border: var(--donation-thermometer-border--); overflow: hidden; display: flex; align-items: center; } .thermometer-fill { - background-color: var(--fill-color--); + background-color: var(--donation-thermometer-fill-color--); text-align: right; height: 100%; display: flex; @@ -289,21 +301,21 @@ export class IADonationThermometer extends LitElement { } .value-left .thermometer-value { - color: var(--value-on-fill-color--); + color: var(--donation-thermometer-value-on-fill-color--); padding: 0 0.5rem 0 1rem; } .value-right .thermometer-value { - color: var(--value-on-track-color--); + color: var(--donation-thermometer-value-on-track-color--); padding: 0 1rem 0 0.5rem; } .donate-goal { text-align: left; - padding: var(--goal-padding--); + padding: var(--donation-thermometer-goal-padding--); text-transform: uppercase; font-weight: bold; - color: var(--goal-color--); + color: var(--donation-thermometer-goal-color--); } `, ]; From fcd9b924ad1538f1ef46e787d3e1feaa01a11197 Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Tue, 15 Sep 2026 12:37:48 -0700 Subject: [PATCH 4/5] WEBDEV-9050: Fix the thermometer's resize loop and progressbar semantics The value label is one node placed by offset from the end of the fill now, instead of being re-rendered into a different parent each time it changes sides. That was raising "ResizeObserver loop completed with undelivered notifications" on every left-to-right swap, and it lands as an uncaught window error rather than a console call, so petabox's error handler would collect it on every banner resize. Four swaps went from 8 errors to none. Only the fill is clipped now, so the label isn't cut off when it sits past the end of the track. role="progressbar" and the aria-value* attributes are on the bar itself rather than a wrapper that also held the goal text, which was keeping the goal out of the accessibility tree. aria-valuenow and aria-valuemax are clamped to a valid range, and an amount or goal that isn't a positive finite number empties the bar instead of emitting width: NaN%. box-sizing on the bar makes it paint at the height it declares, 2px shorter than before. The two new getters are named clear of ARIAMixin, which already declares ariaValueNow and ariaValueMax on every element. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JpyQxVSkZFrWxxGamCcNhY --- .../ia-donation-thermometer.test.ts | 191 +++++++++++++++++- .../ia-donation-thermometer.ts | 183 +++++++++++------ 2 files changed, 310 insertions(+), 64 deletions(-) diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts index 49c1d63a..2b423072 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts @@ -19,9 +19,10 @@ async function settleLayout(el: IADonationThermometer): Promise { /** Renders the thermometer at a known width so the fill sizes are predictable. */ async function sizedFixture( template: TemplateResult, + width = 300, ): Promise { const wrapper = await fixture( - html`
${template}
`, + html`
${template}
`, ); const el = wrapper.querySelector( 'ia-donation-thermometer', @@ -38,6 +39,17 @@ function fill(el: IADonationThermometer): HTMLDivElement { return el.shadowRoot!.querySelector('.thermometer-fill')!; } +function progressbar(el: IADonationThermometer): HTMLDivElement { + return el.shadowRoot!.querySelector('[role="progressbar"]')!; +} + +/** The painted fill as a fraction of the track it is clipped to. */ +function fillFraction(el: IADonationThermometer): number { + const clip = el.shadowRoot!.querySelector('.thermometer-clip')!; + const width = clip.getBoundingClientRect().width; + return width === 0 ? 0 : fill(el).getBoundingClientRect().width / width; +} + function goalMessage(el: IADonationThermometer): HTMLDivElement | null { return el.shadowRoot!.querySelector('.donate-goal'); } @@ -64,7 +76,7 @@ describe('IADonationThermometer', () => { >`, ); - const bar = el.shadowRoot!.querySelector('[role="progressbar"]')!; + const bar = progressbar(el); expect(bar.getAttribute('aria-label')).to.equal('Donation progress'); expect(bar.getAttribute('aria-valuemin')).to.equal('0'); expect(bar.getAttribute('aria-valuemax')).to.equal('1000000'); @@ -79,7 +91,7 @@ describe('IADonationThermometer', () => { >`, ); - const bar = el.shadowRoot!.querySelector('[role="progressbar"]')!; + const bar = progressbar(el); expect(bar.getAttribute('aria-label')).to.equal('End of year goal'); }); @@ -103,7 +115,7 @@ describe('IADonationThermometer', () => { >`, ); - expect(fill(el).style.width).to.equal('25%'); + expect(fillFraction(el)).to.be.closeTo(0.25, 0.01); }); test('caps the fill at 100% once the goal is passed', async () => { @@ -114,7 +126,7 @@ describe('IADonationThermometer', () => { >`, ); - expect(fill(el).style.width).to.equal('100%'); + expect(fillFraction(el)).to.be.closeTo(1, 0.01); }); test('can hide the goal', async () => { @@ -194,7 +206,9 @@ describe('IADonationThermometer', () => { ); expect(background(el).classList.contains('value-right')).to.be.true; - expect(currentValue(el)?.parentElement).to.equal(background(el)); + expect(currentValue(el)?.getBoundingClientRect().left).to.equal( + fill(el).getBoundingClientRect().right, + ); }); test('shows the current value on the left if there is room', async () => { @@ -206,7 +220,9 @@ describe('IADonationThermometer', () => { ); expect(background(el).classList.contains('value-left')).to.be.true; - expect(currentValue(el)?.parentElement).to.equal(fill(el)); + expect(currentValue(el)?.getBoundingClientRect().right).to.equal( + fill(el).getBoundingClientRect().right, + ); }); test('moves the value across as the fill grows and shrinks', async () => { @@ -248,6 +264,167 @@ describe('IADonationThermometer', () => { expect(background(el).classList.contains('value-right')).to.be.true; }); + test('keeps the value label as a single node as it changes sides', async () => { + const el = await sizedFixture( + html``, + ); + const label = currentValue(el); + expect(background(el).classList.contains('value-right')).to.be.true; + + el.currentAmount = 750_000; + await settleLayout(el); + + expect(background(el).classList.contains('value-left')).to.be.true; + expect(currentValue(el)).to.equal(label); + }); + + test('keeps the label out of the fill width', async () => { + const el = await sizedFixture( + html``, + ); + + expect(fillFraction(el)).to.be.closeTo(0.001, 0.002); + expect(currentValue(el)?.getBoundingClientRect().width).to.be.above( + fill(el).getBoundingClientRect().width, + ); + }); + + test('keeps the value label unclipped on a narrow track', async () => { + const el = await sizedFixture( + html``, + 200, + ); + expect(background(el).classList.contains('value-right')).to.be.true; + + // The label runs past the end of the track at this width, so it has to sit + // outside the layer that clips the fill. + const label = currentValue(el); + const clip = el.shadowRoot!.querySelector('.thermometer-clip')!; + + expect(label?.getBoundingClientRect().right).to.be.above( + background(el).getBoundingClientRect().right, + ); + expect(clip.contains(label)).to.be.false; + expect(getComputedStyle(background(el)).overflow).to.equal('visible'); + }); + + test('paints the bar at its declared height', async () => { + const el = await sizedFixture( + html``, + ); + + expect(background(el).getBoundingClientRect().height).to.be.closeTo( + 40, + 0.5, + ); + }); + + test('keeps the goal text out of the progressbar', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(goalMessage(el)).to.exist; + expect(bar).to.equal(background(el)); + expect(bar.contains(goalMessage(el))).to.be.false; + }); + + test('clamps the reported progress to the goal', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(bar.getAttribute('aria-valuenow')).to.equal('1000000'); + expect(bar.getAttribute('aria-valuetext')).to.equal('$1.5MM'); + }); + + test('reports no progress against a zero goal', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(fillFraction(el)).to.equal(0); + expect(bar.getAttribute('aria-valuenow')).to.equal('0'); + }); + + test('empties the fill for a negative amount', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(fillFraction(el)).to.equal(0); + expect(bar.getAttribute('aria-valuenow')).to.equal('0'); + }); + + test('empties the fill for an amount that is not a number', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(fillFraction(el)).to.equal(0); + expect(bar.getAttribute('aria-valuenow')).to.equal('0'); + expect(bar.getAttribute('aria-valuetext')).to.equal('$0'); + expect(currentValue(el)?.textContent?.trim()).to.equal('$0'); + }); + + test('reports a goal that is not a number as no range', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(fillFraction(el)).to.equal(0); + expect(bar.getAttribute('aria-valuemax')).to.equal('0'); + expect(bar.getAttribute('aria-valuenow')).to.equal('0'); + }); + + test('reports a negative goal as no range', async () => { + const el = await fixture( + html``, + ); + + const bar = progressbar(el); + expect(fillFraction(el)).to.equal(0); + expect(bar.getAttribute('aria-valuemax')).to.equal('0'); + expect(bar.getAttribute('aria-valuenow')).to.equal('0'); + }); + test('keeps tracking sizes after being moved in the page', async () => { const el = await sizedFixture( html` -
-
-
-
- ${this.thermometerValuePosition === 'value-left' - ? this.thermometerValueTemplate - : nothing} -
- ${this.thermometerValuePosition === 'value-right' - ? this.thermometerValueTemplate - : nothing} +
+
+
+
+
+ ${this.thermometerValueTemplate}
- ${this.goalMessageMode !== 'off' - ? html`` - : nothing}
+ ${this.goalMessageMode !== 'off' + ? html`` + : nothing}
`; } @@ -110,9 +104,10 @@ export class IADonationThermometer extends LitElement { } /** - * Which side of the fill the value label sits on. It goes inside the fill - * when the fill is wider than the label plus a little breathing room, and - * outside to the right otherwise. + * Which side of the fill's end the value label sits on, as a class name for + * the CSS that places it. The label sits over the fill when the fill is + * wider than the label plus a little breathing room, and just past the + * fill's right edge otherwise. */ private get thermometerValuePosition(): 'value-left' | 'value-right' { const buffer = 10; @@ -135,6 +130,7 @@ export class IADonationThermometer extends LitElement { super.disconnectedCallback(); this.resizeObserver?.disconnect(); this.resizeObserver = undefined; + this.observedFillElement = null; this.observedValueElement = null; } @@ -142,27 +138,43 @@ export class IADonationThermometer extends LitElement { this.observeParts(); } - /** Observes the fill once and follows the value label as it moves. */ + /** Observes the fill and the value label, picking up either if it is replaced. */ private observeParts(): void { const observer = this.resizeObserver; if (!observer) return; - if (this.thermometerFill) observer.observe(this.thermometerFill); - - const valueElement = this.thermometerValue ?? null; - if (valueElement === this.observedValueElement) return; + this.observedFillElement = this.observeInPlaceOf( + observer, + this.observedFillElement, + this.thermometerFill ?? null, + ); + this.observedValueElement = this.observeInPlaceOf( + observer, + this.observedValueElement, + this.thermometerValue ?? null, + ); + } - if (this.observedValueElement) - observer.unobserve(this.observedValueElement); - if (valueElement) observer.observe(valueElement); - this.observedValueElement = valueElement; + /** + * Swaps `observed` for `current` on the observer and returns whichever is + * now being watched. A target that hasn't changed is left alone. + */ + private observeInPlaceOf( + observer: ResizeObserver, + observed: Element | null, + current: Element | null, + ): Element | null { + if (current === observed) return observed; + if (observed) observer.unobserve(observed); + if (current) observer.observe(current); + return current; } private handleResize(entries: ResizeObserverEntry[]): void { for (const entry of entries) { const width = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; - if (entry.target === this.thermometerFill) { + if (entry.target === this.observedFillElement) { this.thermometerFillWidth = width; } else if (entry.target === this.observedValueElement) { this.thermometerValueWidth = width; @@ -190,7 +202,7 @@ export class IADonationThermometer extends LitElement { * number. */ private formatNumber(number: number): string { - if (number === 0) return '$0'; + if (!Number.isFinite(number) || number === 0) return '$0'; const suffix = 'MM'; const divisor = 1_000_000; const result = number / divisor; @@ -212,8 +224,37 @@ export class IADonationThermometer extends LitElement { } } + /** + * The goal the bar measures against. A goal that isn't a positive, finite + * number leaves the bar empty, since there is no range to report progress + * against. Named clear of `ariaValueMax`, which `HTMLElement` declares as a + * public string. + */ + private get progressMax(): number { + return Number.isFinite(this.goalAmount) && this.goalAmount > 0 + ? this.goalAmount + : 0; + } + + /** + * The progress reported to screen readers, clamped into the + * `aria-valuemin`/`aria-valuemax` range the spec requires. + * `aria-valuetext` carries the amount as it is displayed. + */ + private get progressValue(): number { + if (!Number.isFinite(this.currentAmount)) return 0; + return Math.min(Math.max(this.currentAmount, 0), this.progressMax); + } + + /** + * How much of the bar is filled, as a percentage, clamped to `0`-`100`. An + * amount or goal that can't produce a usable percentage empties the bar. + */ private get percentComplete(): number { - return Math.min((this.currentAmount / this.goalAmount) * 100, 100); + const goal = this.progressMax; + if (goal === 0 || !Number.isFinite(this.currentAmount)) return 0; + const percent = (this.currentAmount / goal) * 100; + return Math.min(Math.max(percent, 0), 100); } static get styles(): CSSResultGroup { @@ -260,10 +301,6 @@ export class IADonationThermometer extends LitElement { height: var(--donation-thermometer-height--); } - .container { - height: 100%; - } - .thermometer-message-container { height: 100%; display: flex; @@ -276,31 +313,63 @@ export class IADonationThermometer extends LitElement { } .thermometer-background { + box-sizing: border-box; + position: relative; background-color: var(--donation-thermometer-track-color--); - padding: 0; height: 100%; border-radius: var(--donation-thermometer-border-radius--); border: var(--donation-thermometer-border--); + } + + /* + * Clips the square-ended fill to the rounded track. It covers only the + * fill, so the label is never cut off by it. + */ + .thermometer-clip { + position: absolute; + inset: 0; overflow: hidden; - display: flex; - align-items: center; + border-radius: var(--donation-thermometer-border-radius--); } .thermometer-fill { background-color: var(--donation-thermometer-fill-color--); - text-align: right; height: 100%; - display: flex; - justify-content: flex-end; - align-items: center; + /* + * The fallback matters: without it a missing --fill-end-- makes the + * declaration invalid, width resolves to auto, and the bar paints as + * a fully funded fundraiser. + */ + width: var(--fill-end--, 0%); } + /* + * The label is placed by offset from the end of the fill, so it is one + * node in one position in the DOM whichever side it shows on, and it + * never counts towards the width of the fill it is measured against. + */ .thermometer-value { + position: absolute; + top: 0; + bottom: 0; + left: var(--fill-end--, 0%); + /* + * Without this, the left offset caps the shrink-to-fit width of a + * translated label and it collapses to its padding. + */ + width: max-content; + display: flex; + align-items: center; font-weight: bold; white-space: nowrap; } + /* + * Both paddings total 1.5rem, so changing sides leaves the label the + * same width and cannot feed back into the side it is placed on. + */ .value-left .thermometer-value { + transform: translateX(-100%); color: var(--donation-thermometer-value-on-fill-color--); padding: 0 0.5rem 0 1rem; } From 9f25dfd6a70922e6c027d048836cc74f4a1af34b Mon Sep 17 00:00:00 2001 From: Jason Buckner Date: Thu, 24 Sep 2026 11:20:01 -0700 Subject: [PATCH 5/5] WEBDEV-9050: Register the thermometer with the guarded customElement Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013RS8ThjiMNiq7HBBAGash1 --- .../ia-donation-thermometer/ia-donation-thermometer-story.ts | 2 +- .../ia-donation-thermometer/ia-donation-thermometer.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts index c52d56e0..a6206cca 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer-story.ts @@ -1,5 +1,5 @@ import { html, LitElement } from 'lit'; -import { customElement } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; import type { StyleInputSettings } from '@demo/story-components/story-styles-settings'; diff --git a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts index 36992e73..a2f5eb89 100644 --- a/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts @@ -6,7 +6,8 @@ import { type CSSResultGroup, type TemplateResult, } from 'lit'; -import { customElement, property, query, state } from 'lit/decorators.js'; +import { property, query, state } from 'lit/decorators.js'; +import { customElement } from '@src/util/custom-element'; import { msg, str } from '@lit/localize'; import themeStyles from '@src/themes/theme-styles';