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 0000000..2d40fe8 --- /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 0000000..a6206cc --- /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 '@src/util/custom-element'; + +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 0000000..2b42307 --- /dev/null +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.test.ts @@ -0,0 +1,444 @@ +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, + width = 300, +): 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 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'); +} + +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 = 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'); + 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 = progressbar(el); + 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(fillFraction(el)).to.be.closeTo(0.25, 0.01); + }); + + test('caps the fill at 100% once the goal is passed', async () => { + const el = await fixture( + html``, + ); + + expect(fillFraction(el)).to.be.closeTo(1, 0.01); + }); + + 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)?.getBoundingClientRect().left).to.equal( + fill(el).getBoundingClientRect().right, + ); + }); + + 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)?.getBoundingClientRect().right).to.equal( + fill(el).getBoundingClientRect().right, + ); + }); + + 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 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``, + ); + 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 0000000..a2f5eb8 --- /dev/null +++ b/src/elements/ia-donation-thermometer/ia-donation-thermometer.ts @@ -0,0 +1,393 @@ +import { + css, + html, + LitElement, + nothing, + type CSSResultGroup, + type TemplateResult, +} from 'lit'; +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'; + +/** 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 observed nodes are tracked so each is observed exactly once. Calling + * `observe` on an already-observed target re-reports its size, which costs a + * notification on every render. Lit also discards the value label when + * `currentAmountMode` turns it off, so a replacement has to be picked up. + */ + private observedFillElement: Element | null = null; + + private observedValueElement: Element | null = null; + + render(): TemplateResult { + return html` +
+
+
+
+
+
+ ${this.thermometerValueTemplate} +
+
+ ${this.goalMessageMode !== 'off' + ? html`` + : nothing} +
+ `; + } + + private get thermometerValueTemplate(): TemplateResult | typeof nothing { + return this.currentAmountMode === 'off' + ? nothing + : html` +
${this.currentAmountDisplayValue}
+ `; + } + + /** + * 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; + 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.observedFillElement = null; + this.observedValueElement = null; + } + + updated(): void { + this.observeParts(); + } + + /** Observes the fill and the value label, picking up either if it is replaced. */ + private observeParts(): void { + const observer = this.resizeObserver; + if (!observer) return; + + this.observedFillElement = this.observeInPlaceOf( + observer, + this.observedFillElement, + this.thermometerFill ?? null, + ); + this.observedValueElement = this.observeInPlaceOf( + observer, + this.observedValueElement, + this.thermometerValue ?? null, + ); + } + + /** + * 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.observedFillElement) { + 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.isFinite(number) || 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 ''; + } + } + + /** + * 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 { + 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 { + return [ + themeStyles, + css` + :host { + --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(--donation-thermometer-fill-color--) + ); + /* Large enough to round any height into a pill */ + --donation-thermometer-border-radius--: var( + --ia-donation-thermometer-border-radius, + 9999px + ); + --donation-thermometer-value-on-fill-color--: var( + --ia-donation-thermometer-value-on-fill-color, + var(--true-white) + ); + --donation-thermometer-value-on-track-color--: var( + --ia-donation-thermometer-value-on-track-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 + ); + + display: block; + height: var(--donation-thermometer-height--); + } + + .thermometer-message-container { + height: 100%; + display: flex; + align-items: center; + } + + .thermometer-container { + height: 100%; + flex: 1; + } + + .thermometer-background { + box-sizing: border-box; + position: relative; + background-color: var(--donation-thermometer-track-color--); + 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; + border-radius: var(--donation-thermometer-border-radius--); + } + + .thermometer-fill { + background-color: var(--donation-thermometer-fill-color--); + height: 100%; + /* + * 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; + } + + .value-right .thermometer-value { + color: var(--donation-thermometer-value-on-track-color--); + padding: 0 1rem 0 0.5rem; + } + + .donate-goal { + text-align: left; + padding: var(--donation-thermometer-goal-padding--); + text-transform: uppercase; + font-weight: bold; + color: var(--donation-thermometer-goal-color--); + } + `, + ]; + } +} diff --git a/src/elements/index.ts b/src/elements/index.ts index c344327..9dad8f0 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';