From 18506f04602060a4818e8aec5472dfd5cac5b882 Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Wed, 27 May 2026 15:36:48 -0400 Subject: [PATCH 01/16] Add Offshoot button to elements --- src/elements/ia-button/ia-button.test.ts | 99 +++++++- src/elements/ia-button/ia-button.ts | 298 +++++++++++++++++++++-- 2 files changed, 372 insertions(+), 25 deletions(-) diff --git a/src/elements/ia-button/ia-button.test.ts b/src/elements/ia-button/ia-button.test.ts index cbd088d..46fb5e9 100644 --- a/src/elements/ia-button/ia-button.test.ts +++ b/src/elements/ia-button/ia-button.test.ts @@ -2,13 +2,106 @@ import { fixture } from '@open-wc/testing-helpers'; import { describe, expect, test } from 'vitest'; import { html } from 'lit'; -import type { IAButton } from './ia-button'; +import { IAButton } from './ia-button'; import './ia-button'; describe('IA button', () => { test('renders a basic button', async () => { - const el = await fixture(html`Click me`); + const el = await fixture(html`Submit`); + + const button = el.shadowRoot?.querySelector('button'); + expect(button).to.exist; + expect(button?.disabled).to.equal(false); + }); + + test('displays slotted text within button', async () => { + const el = await fixture( + html`Submit`, + ); + + const buttonText = el.shadowRoot + ?.querySelector('slot') + ?.assignedElements()[0]; + expect(buttonText).to.exist; + expect(buttonText?.innerHTML).to.contain('Submit'); + }); + + test('shows a loading state if requested', async () => { + const el = await fixture( + html``, + ); + + const button = el.shadowRoot?.querySelector('button'); + expect(button?.disabled).to.equal(true); + expect(button?.innerText).to.equal(''); + + const loadingIndicator = button?.querySelector('.loading-indicator'); + expect(loadingIndicator).to.exist; + }); + + test('shows text next to the loading indicator if requested', async () => { + const el = await fixture(html` + + Submit + + `); + + const button = el.shadowRoot?.querySelector('button'); + expect(button?.disabled).to.equal(true); + expect(button?.innerText).to.equal('Loading...'); + + const loadingIndicator = button?.querySelector('.loading-indicator'); + expect(loadingIndicator).to.exist; + }); + + test('adds a hidden light DOM submit input if type set to submit', async () => { + const el = await fixture(html` + Submit + `); + + await el.updateComplete; + + const hiddenInput = el.querySelector('input[type="submit"]'); + expect(hiddenInput).to.exist; + }); + + test('does add a hidden light DOM reset input if type set to reset', async () => { + const el = await fixture(html` + Clear + `); + + await el.updateComplete; + + const hiddenInput = el.querySelector('input[type="reset"]'); + expect(hiddenInput).to.exist; + }); + + test('does not add a hidden light DOM input if type not set', async () => { + const el = await fixture(html`Submit`); + + await el.updateComplete; + + const hiddenInput = el.querySelector('input[type="submit"]'); + expect(hiddenInput).not.to.exist; + }); + + test('does not add a hidden light DOM input if type set to button', async () => { + const el = await fixture( + html`Submit`, + ); + + await el.updateComplete; + + const hiddenInput = el.querySelector('input[type="submit"]'); + expect(hiddenInput).not.to.exist; + }); + + test('disables the button if requested', async () => { + const el = await fixture( + html`Submit`, + ); + const button = el.shadowRoot?.querySelector('button'); - expect(button).toBeDefined(); + expect(button?.disabled).to.equal(true); }); }); diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index 7b6153e..3e91c76 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -1,36 +1,290 @@ -import { css, html, LitElement, type CSSResultGroup } from 'lit'; -import { customElement } from 'lit/decorators/custom-element.js'; +import { + html, + LitElement, + TemplateResult, + CSSResultGroup, + css, + PropertyValues, + render, +} from 'lit'; +import { msg } from '@lit/localize'; +import { property, customElement } from 'lit/decorators.js'; -import themeStyles from '@src/themes/theme-styles'; +import '../ia-status-indicator/ia-status-indicator'; /** - * A button element to demo the elements library + * Renders a standardized button built off the ia-button styling and the activity indicator, with the option to show + * a loading indicator instead of the text or to customize the text, and the ability to pass in any + * function to run on click. + * + * Designed to behave exactly like a native type="submit" or type="reset" button if either type is supplied, + * i.e. for an , + * whether the form is submitted via enter or by clicking the button directly, the button will always + * run doSideEffects() and then submit the form. */ @customElement('ia-button') export class IAButton extends LitElement { - render() { + /* Whether to show a loading indicator instead of the button */ + @property({ type: Boolean }) loading: boolean = false; + + /* Whether the button should be disabled, regardless of submission status */ + @property({ type: Boolean }) disabled: boolean = false; + + /* Optional text to include next to the loading indicator */ + @property({ type: String }) loadingText: string = ''; + + /* Type of the button - defaults to 'button' to prevent form submission */ + @property({ type: String, reflect: true }) type: + | 'button' + | 'submit' + | 'reset' = 'button'; + + render(): TemplateResult { return html` - + + `; + } + + protected willUpdate(changed: PropertyValues): void { + if (changed.has('type')) { + this.setButtonTypeEmulation(); + } + } + + /* Content to render while button is loading */ + private get loadingStateTemplate(): TemplateResult { + return html` + + ${msg( + this.loadingText, + )} + `; } + /** Sets up or removes button type emulation as needed */ + private setButtonTypeEmulation(): void { + this.removeExistingTypeEmulation(); + this.emulateButtonTypeBehavior(); + } + + /** + * Removes all existing button behavior emulation + * to ensure any new button type takes precendence. + */ + private removeExistingTypeEmulation(): void { + this.removeEventListener('click', this.handleComponentClick); + this.querySelector('.hidden-button')?.remove(); + } + + /** + * If the button type set to "submit" or "reset", + * adds extra behavior to ensure + * button emulates native button behavior. + * */ + private emulateButtonTypeBehavior(): void { + if (this.type === 'button') return; + + this.addHiddenButton(); + this.addEventListener('click', this.handleComponentClick); + } + + /** + * Triggers form actions (submit/reset) on click, if desired + * and if actions not already in progress + */ + private handleComponentClick(e: Event): void { + if (this.type === 'button') return; + + // No need to submit/reset the form if it's already submitting/resetting + const formActionsInProgress = + e instanceof CustomEvent && e.detail.formActionsInProgress; + if (formActionsInProgress) return; + + // Request form submission/reset by clicking the hidden native submit/reset button we added earlier + const hiddenButton = this.querySelector( + 'input.hidden-button', + ) as HTMLInputElement; + hiddenButton.dispatchEvent(new PointerEvent('click')); + } + + /** + * Adds a hidden button to the light DOM to + * emulate native reset/submit button behavior. + * + * Used for: + * - Submitting/resetting the form (on behalf of handleComponentClick) + * - Activating any component click events if form is submitted with the enter key + * (via handleFormActions) + */ + private addHiddenButton(): void { + if (this.type === 'button') return; + + render( + html` this.handleFormActions(e)} + />`, + this, + ); + } + + /** Triggers the component's click event manually to prevent extra submission/reset */ + private handleFormActions(e: Event): void { + /** + * Prevents the event from bubbling up to the ia-button component itself. + * This way, if the click on this button is triggered by a click on the component, + * we don't accidentally bubble up a second click and end up in an infinite loop. + * + * And if the click was triggered by form submission (via the enter key), we can hijack the click + * event to pass that info to the component, so it knows not to + * re-attempt submission. + */ + e.stopPropagation(); + + // Detect if click comes from the parent component, + // in which case there's no need to trigger a second click on it + const triggeredByComponentClick = !e.isTrusted; + if (triggeredByComponentClick) return; + + // Trigger parent component click events manually, + // so it knows not to try to re-submit/re-reset the form + this.dispatchEvent( + new CustomEvent('click', { detail: { formActionsInProgress: true } }), + ); + } + static get styles(): CSSResultGroup { - return [ - themeStyles, - css` - :host { - --primary-background-color--: var(--primary-cta-fill); - --primary-text-color--: var(--primary-cta-text-color); - } - - button { - padding: 8px 16px; - background-color: var(--primary-background-color--); - color: var(--primary-text-color--); - } - `, - ]; + return css` + :host { + display: inline-block; /* keeps host sized to button */ + } + + button { + height: var(--height, 3.5rem); + min-height: 3rem; + cursor: pointer; + color: var(--textColor, #fff); + background: var(--backgroundColor, initial); + line-height: normal; + border-radius: 0.4rem; + font-size: var(--fontSize, 1.4rem); + font-family: inherit; + border: var(--borderWidth, 1px) solid var(--borderColor, transparent); + white-space: nowrap; + appearance: auto; + box-sizing: border-box; + display: flex; + align-items: center; + transition: var(--transition, all 0.1s ease 0s); + vertical-align: middle; + padding: var(--padding, 0 3rem); + outline-color: var(--textColor, #fff); + outline-offset: -4px; + user-select: none; + text-decoration: none; + width: var(--width, fit-content); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + } + + button:disabled { + cursor: not-allowed; + background-color: var(--primaryDisableCTAFill, #767676); + border: 1px solid var(--secondaryCTABorder, #999); + opacity: 0.5; + } + + button:enabled:hover { + opacity: 0.9; + } + + button:focus-visible { + opacity: 0.8; + outline-style: double; + } + + button:active { + opacity: 0.7; + } + + :host(.primary) button:enabled { + background-color: var(--primaryCTAFill, #194880); + border-color: var(--primaryCTABorder, #c5d1df); + } + + :host(.danger) button:enabled { + background-color: var(--primaryErrorCTAFill, #d9534f); + border-color: var(--primaryErrorCTABorder, #d43f3a); + } + + :host(.dark) button:enabled { + background-color: var(--secondaryCTAFill, #333); + border-color: var(--primaryCTABorder, #979797); + } + + :host(.warning) button:enabled { + background-color: var(--warningCTAFill, #ee8950); + border-color: var(--warningCTABorder, #ec7939); + } + + :host(.custom-active-color) button:enabled:is(:hover, :focus, :active), + :host(.custom-active-color.active) button:enabled { + color: var(--customActiveForegroundColor, #194880); + background-color: var(--customActiveBackgroundColor, #c5d1df); + border-color: var( + --customActiveBorderColor, + --customActiveForegroundColor, + #194880 + ); + } + + :host(.transparent) button:enabled { + background-color: transparent; + } + + :host(.fit-content) button { + padding: 0; + height: fit-content; + } + + :host(.link) button { + margin: 0; + border: 0; + appearance: none; + background: none; + color: var(--ia-theme-link-color, #4b64ff); + text-decoration: none; + cursor: pointer; + padding: 0; + } + + :host(.link.danger-link) button { + color: var(--ia-theme-danger-link-color, #c9302c); + } + + :host(.link) button:hover { + text-decoration: underline; + } + + .loading-indicator { + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + } + + ia-status-indicator { + --primary-text-color: var(--textColor, #fff); + } + `; } } From 98719f62ce85cea4309c2537dad9d67f38fd1076 Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Wed, 27 May 2026 15:38:12 -0400 Subject: [PATCH 02/16] Add theme styles --- src/elements/ia-button/ia-button.ts | 254 ++++++++++++++-------------- 1 file changed, 129 insertions(+), 125 deletions(-) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index 3e91c76..bdfc940 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -9,6 +9,7 @@ import { } from 'lit'; import { msg } from '@lit/localize'; import { property, customElement } from 'lit/decorators.js'; +import themeStyles from '@src/themes/theme-styles'; import '../ia-status-indicator/ia-status-indicator'; @@ -161,130 +162,133 @@ export class IAButton extends LitElement { } static get styles(): CSSResultGroup { - return css` - :host { - display: inline-block; /* keeps host sized to button */ - } - - button { - height: var(--height, 3.5rem); - min-height: 3rem; - cursor: pointer; - color: var(--textColor, #fff); - background: var(--backgroundColor, initial); - line-height: normal; - border-radius: 0.4rem; - font-size: var(--fontSize, 1.4rem); - font-family: inherit; - border: var(--borderWidth, 1px) solid var(--borderColor, transparent); - white-space: nowrap; - appearance: auto; - box-sizing: border-box; - display: flex; - align-items: center; - transition: var(--transition, all 0.1s ease 0s); - vertical-align: middle; - padding: var(--padding, 0 3rem); - outline-color: var(--textColor, #fff); - outline-offset: -4px; - user-select: none; - text-decoration: none; - width: var(--width, fit-content); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -o-user-select: none; - } - - button:disabled { - cursor: not-allowed; - background-color: var(--primaryDisableCTAFill, #767676); - border: 1px solid var(--secondaryCTABorder, #999); - opacity: 0.5; - } - - button:enabled:hover { - opacity: 0.9; - } - - button:focus-visible { - opacity: 0.8; - outline-style: double; - } - - button:active { - opacity: 0.7; - } - - :host(.primary) button:enabled { - background-color: var(--primaryCTAFill, #194880); - border-color: var(--primaryCTABorder, #c5d1df); - } - - :host(.danger) button:enabled { - background-color: var(--primaryErrorCTAFill, #d9534f); - border-color: var(--primaryErrorCTABorder, #d43f3a); - } - - :host(.dark) button:enabled { - background-color: var(--secondaryCTAFill, #333); - border-color: var(--primaryCTABorder, #979797); - } - - :host(.warning) button:enabled { - background-color: var(--warningCTAFill, #ee8950); - border-color: var(--warningCTABorder, #ec7939); - } - - :host(.custom-active-color) button:enabled:is(:hover, :focus, :active), - :host(.custom-active-color.active) button:enabled { - color: var(--customActiveForegroundColor, #194880); - background-color: var(--customActiveBackgroundColor, #c5d1df); - border-color: var( - --customActiveBorderColor, - --customActiveForegroundColor, - #194880 - ); - } - - :host(.transparent) button:enabled { - background-color: transparent; - } - - :host(.fit-content) button { - padding: 0; - height: fit-content; - } - - :host(.link) button { - margin: 0; - border: 0; - appearance: none; - background: none; - color: var(--ia-theme-link-color, #4b64ff); - text-decoration: none; - cursor: pointer; - padding: 0; - } - - :host(.link.danger-link) button { - color: var(--ia-theme-danger-link-color, #c9302c); - } - - :host(.link) button:hover { - text-decoration: underline; - } - - .loading-indicator { - display: flex; - flex-direction: row; - gap: 0.5rem; - align-items: center; - } - - ia-status-indicator { - --primary-text-color: var(--textColor, #fff); - } - `; + return [ + themeStyles, + css` + :host { + display: inline-block; /* keeps host sized to button */ + } + + button { + height: var(--height, 3.5rem); + min-height: 3rem; + cursor: pointer; + color: var(--textColor, #fff); + background: var(--backgroundColor, initial); + line-height: normal; + border-radius: 0.4rem; + font-size: var(--fontSize, 1.4rem); + font-family: inherit; + border: var(--borderWidth, 1px) solid var(--borderColor, transparent); + white-space: nowrap; + appearance: auto; + box-sizing: border-box; + display: flex; + align-items: center; + transition: var(--transition, all 0.1s ease 0s); + vertical-align: middle; + padding: var(--padding, 0 3rem); + outline-color: var(--textColor, #fff); + outline-offset: -4px; + user-select: none; + text-decoration: none; + width: var(--width, fit-content); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + } + + button:disabled { + cursor: not-allowed; + background-color: var(--primaryDisableCTAFill, #767676); + border: 1px solid var(--secondaryCTABorder, #999); + opacity: 0.5; + } + + button:enabled:hover { + opacity: 0.9; + } + + button:focus-visible { + opacity: 0.8; + outline-style: double; + } + + button:active { + opacity: 0.7; + } + + :host(.primary) button:enabled { + background-color: var(--primaryCTAFill, #194880); + border-color: var(--primaryCTABorder, #c5d1df); + } + + :host(.danger) button:enabled { + background-color: var(--primaryErrorCTAFill, #d9534f); + border-color: var(--primaryErrorCTABorder, #d43f3a); + } + + :host(.dark) button:enabled { + background-color: var(--secondaryCTAFill, #333); + border-color: var(--primaryCTABorder, #979797); + } + + :host(.warning) button:enabled { + background-color: var(--warningCTAFill, #ee8950); + border-color: var(--warningCTABorder, #ec7939); + } + + :host(.custom-active-color) button:enabled:is(:hover, :focus, :active), + :host(.custom-active-color.active) button:enabled { + color: var(--customActiveForegroundColor, #194880); + background-color: var(--customActiveBackgroundColor, #c5d1df); + border-color: var( + --customActiveBorderColor, + --customActiveForegroundColor, + #194880 + ); + } + + :host(.transparent) button:enabled { + background-color: transparent; + } + + :host(.fit-content) button { + padding: 0; + height: fit-content; + } + + :host(.link) button { + margin: 0; + border: 0; + appearance: none; + background: none; + color: var(--ia-theme-link-color, #4b64ff); + text-decoration: none; + cursor: pointer; + padding: 0; + } + + :host(.link.danger-link) button { + color: var(--ia-theme-danger-link-color, #c9302c); + } + + :host(.link) button:hover { + text-decoration: underline; + } + + .loading-indicator { + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + } + + ia-status-indicator { + --primary-text-color: var(--textColor, #fff); + } + `, + ]; } } From 1cbe5e5bcebf42f0175dcc90be724d3a1bd9014f Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Wed, 27 May 2026 15:56:40 -0400 Subject: [PATCH 03/16] Extract styles for different modes --- src/elements/ia-button/ia-button.ts | 61 +++++++++++++++++++++-------- src/themes/theme-styles.ts | 55 ++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index bdfc940..4605339 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -166,6 +166,29 @@ export class IAButton extends LitElement { themeStyles, css` :host { + --primary-cta-text-color--: var(--primary-cta-text-color); + --primary-cta-fill--: var(--primary-cta-fill); + --primary-cta-border--: var(--primary-cta-border); + + --secondary-cta-text-color--: var(--secondary-cta-text-color); + --secondary-cta-fill--: var(--secondary-cta-fill); + --secondary-cta-border--: var(--secondary-cta-border); + + --danger-cta-text-color--: var(--danger-cta-text-color); + --danger-cta-fill--: var(--danger-cta-fill); + --danger-cta-border--: var(--danger-cta-border); + + --warning-cta-text-color--: var(--warning-cta-text-color); + --warning-cta-fill--: var(--warning-cta-fill); + --warning-cta-border--: var(--warning-cta-border); + + --disabled-cta-text-color--: var(--disabled-cta-text-color); + --disabled-cta-fill--: var(--disabled-cta-fill); + --disabled-cta-border--: var(--disabled-cta-border); + + --link-color--: var(--link-color); + --color-danger--: var(--color-danger); + display: inline-block; /* keeps host sized to button */ } @@ -173,13 +196,13 @@ export class IAButton extends LitElement { height: var(--height, 3.5rem); min-height: 3rem; cursor: pointer; - color: var(--textColor, #fff); - background: var(--backgroundColor, initial); + color: var(--primary-cta-text-color--); + background: var(--primary-cta-fill--); line-height: normal; border-radius: 0.4rem; font-size: var(--fontSize, 1.4rem); font-family: inherit; - border: var(--borderWidth, 1px) solid var(--borderColor, transparent); + border: var(--borderWidth, 1px) solid var(--primary-cta-border--); white-space: nowrap; appearance: auto; box-sizing: border-box; @@ -188,7 +211,7 @@ export class IAButton extends LitElement { transition: var(--transition, all 0.1s ease 0s); vertical-align: middle; padding: var(--padding, 0 3rem); - outline-color: var(--textColor, #fff); + outline-color: var(--primary-cta-text-color--); outline-offset: -4px; user-select: none; text-decoration: none; @@ -201,8 +224,9 @@ export class IAButton extends LitElement { button:disabled { cursor: not-allowed; - background-color: var(--primaryDisableCTAFill, #767676); - border: 1px solid var(--secondaryCTABorder, #999); + color: var(--disabled-cta-color--); + background-color: var(--disabled-cta-fill--); + border: 1px solid var(--disabled-cta-border--); opacity: 0.5; } @@ -220,23 +244,26 @@ export class IAButton extends LitElement { } :host(.primary) button:enabled { - background-color: var(--primaryCTAFill, #194880); - border-color: var(--primaryCTABorder, #c5d1df); + color: var(--primary-cta-text-color--); + background-color: var(--primary-cta-fill--); + border-color: var(--primary-cta-border--); } :host(.danger) button:enabled { - background-color: var(--primaryErrorCTAFill, #d9534f); - border-color: var(--primaryErrorCTABorder, #d43f3a); + color: var(--danger-cta-text-color--); + background-color: var(--danger-cta-fill--); + border-color: var(--danger-cta-border--); } :host(.dark) button:enabled { - background-color: var(--secondaryCTAFill, #333); - border-color: var(--primaryCTABorder, #979797); + background-color: var(--secondary-cta-text-color--); + border-color: var(--secondary-cta-fill--); } :host(.warning) button:enabled { - background-color: var(--warningCTAFill, #ee8950); - border-color: var(--warningCTABorder, #ec7939); + color: var(--warning-cta-text-color--); + background-color: var(--warning-cta-fill--); + border-color: var(--warning-cta-border--); } :host(.custom-active-color) button:enabled:is(:hover, :focus, :active), @@ -264,14 +291,14 @@ export class IAButton extends LitElement { border: 0; appearance: none; background: none; - color: var(--ia-theme-link-color, #4b64ff); + color: var(--link-color--); text-decoration: none; cursor: pointer; padding: 0; } :host(.link.danger-link) button { - color: var(--ia-theme-danger-link-color, #c9302c); + color: var(--color-danger--); } :host(.link) button:hover { @@ -286,7 +313,7 @@ export class IAButton extends LitElement { } ia-status-indicator { - --primary-text-color: var(--textColor, #fff); + --ia-theme-primary-text-color: currentColor; } `, ]; diff --git a/src/themes/theme-styles.ts b/src/themes/theme-styles.ts index 34554df..faaa5c8 100644 --- a/src/themes/theme-styles.ts +++ b/src/themes/theme-styles.ts @@ -24,8 +24,15 @@ const themeStyles = css` --true-white: #fff; --off-white: #fbfbfd; --dark-gray: #2c2c2c; + --mid-gray: #333; --light-gray: #666; + --lighter-gray: #999; + --lightest-gray: #c5d1df; --classic-red: #e51c23; + --brick: #d43f3a; + --coral: #d9534f; + --dark-cantaloupe: #ec7939; + --cantaloupe: #ee8950; --mint-green: #31a481; --navy-blue: #194880; --bright-blue: #4b64ff; @@ -79,11 +86,59 @@ const themeStyles = css` ); /* State colors */ + /* Primary */ --primary-cta-fill: var(--ia-theme-primary-cta-fill, var(--navy-blue)); --primary-cta-text-color: var( --ia-theme-primary-cta-text-color, var(--true-white) ); + --primary-cta-border: var( + --ia-theme-primary-cta-border, + var(--lightest-gray) + ); + + /* Secondary */ + --secondary-cta-text-color: var( + --ia-theme-secondary-cta-text-color, + var(--true-white) + ); + --secondary-cta-fill: var(--ia-theme-secondary-cta-fill, var(--mid-gray)); + --secondary-cta-border: var( + --ia-theme-secondary-cta-border, + var(--lighter-gray) + ); + + /* Danger */ + --danger-cta-text-color: var( + --ia-theme-danger-cta-text-color, + var(--true-white) + ); + --danger-cta-fill: var(--ia-theme-danger-cta-fill, var(--coral)); + --danger-cta-border: var(--ia-theme-danger-cta-border, var(--brick)); + + /* Warning */ + --warning-cta-text-color: var( + --ia-theme-warning-cta-text-color, + var(--true-white) + ); + --warning-cta-fill: var(--ia-theme-warning-cta-fill, var(--cantaloupe)); + --warning-cta-border: var( + --ia-theme-warning-cta-border, + var(--dark-cantaloupe) + ); + + /* Disabled */ + --disabled-cta-text-color: var( + --ia-theme-disabled-cta-text-color, + var(--true-white) + ); + --disabled-cta-fill: var(--ia-theme-disabled-cta-fill, var(--light-gray)); + --disabled-cta-border: var( + --ia-theme-disabled-cta-border, + var(--lighter-gray) + ); + + /* Standalone colors */ --color-success: var(--ia-theme-color-success, var(--mint-green)); --color-danger: var(--ia-theme-color-danger, var(--classic-red)); } From 37c8ebd06bdfe9d3915447fbae35f3315672720c Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Wed, 27 May 2026 16:23:01 -0400 Subject: [PATCH 04/16] Extract other styles --- src/elements/ia-button/ia-button.ts | 96 +++++++++++++++++++---------- src/themes/theme-styles.ts | 17 +++++ 2 files changed, 82 insertions(+), 31 deletions(-) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index 4605339..a585178 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -25,6 +25,17 @@ import '../ia-status-indicator/ia-status-indicator'; */ @customElement('ia-button') export class IAButton extends LitElement { + /* Which version of the button to display */ + @property({ type: String }) mode: + | 'primary' + | 'secondary' + | 'danger' + | 'warning' + | 'disabled' + | 'transparent' + | 'link' + | 'danger-link' = 'primary'; + /* Whether to show a loading indicator instead of the button */ @property({ type: Boolean }) loading: boolean = false; @@ -42,7 +53,11 @@ export class IAButton extends LitElement { render(): TemplateResult { return html` - @@ -189,40 +204,51 @@ export class IAButton extends LitElement { --link-color--: var(--link-color); --color-danger--: var(--color-danger); + --button-padding--: var(--button-padding); + --button-width--: var(--button-width); + --button-height--: var(--button-height); + --button-border-width--: var(--button-border-width); + + --ia-button-transition--: var( + --ia-button-transition, + all 0.1s ease 0s + ); + display: inline-block; /* keeps host sized to button */ } button { - height: var(--height, 3.5rem); - min-height: 3rem; + height: var(--button-height--); + min-height: var(--button-height--); + width: var(--button-width--); + padding: var(--button-padding--); + font-size: var(--font-size-standard--); + border-width: var(--button-border-width--); + transition: var(--ia-button-transition--); + outline-color: var(--primary-cta-text-color--); + + font-family: inherit; cursor: pointer; - color: var(--primary-cta-text-color--); - background: var(--primary-cta-fill--); line-height: normal; border-radius: 0.4rem; - font-size: var(--fontSize, 1.4rem); - font-family: inherit; - border: var(--borderWidth, 1px) solid var(--primary-cta-border--); + border-style: 'solid'; white-space: nowrap; appearance: auto; box-sizing: border-box; display: flex; align-items: center; - transition: var(--transition, all 0.1s ease 0s); vertical-align: middle; - padding: var(--padding, 0 3rem); - outline-color: var(--primary-cta-text-color--); outline-offset: -4px; user-select: none; text-decoration: none; - width: var(--width, fit-content); -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; -o-user-select: none; } - button:disabled { + button:disabled, + button.disabled { cursor: not-allowed; color: var(--disabled-cta-color--); background-color: var(--disabled-cta-fill--); @@ -243,29 +269,36 @@ export class IAButton extends LitElement { opacity: 0.7; } - :host(.primary) button:enabled { + button.primary { color: var(--primary-cta-text-color--); background-color: var(--primary-cta-fill--); border-color: var(--primary-cta-border--); } - :host(.danger) button:enabled { + button.secondary { + color: var(--secondary-cta-text-color--); + background-color: var(--secondary-cta-text-color--); + border-color: var(--secondary-cta-fill--); + } + + button.danger { color: var(--danger-cta-text-color--); background-color: var(--danger-cta-fill--); border-color: var(--danger-cta-border--); } - :host(.dark) button:enabled { - background-color: var(--secondary-cta-text-color--); - border-color: var(--secondary-cta-fill--); - } - - :host(.warning) button:enabled { + button.warning { color: var(--warning-cta-text-color--); background-color: var(--warning-cta-fill--); border-color: var(--warning-cta-border--); } + button.transparent { + border-width: 0; + background-color: transparent; + border-color: transparent; + } + :host(.custom-active-color) button:enabled:is(:hover, :focus, :active), :host(.custom-active-color.active) button:enabled { color: var(--customActiveForegroundColor, #194880); @@ -277,32 +310,33 @@ export class IAButton extends LitElement { ); } - :host(.transparent) button:enabled { - background-color: transparent; - } - :host(.fit-content) button { padding: 0; height: fit-content; } - :host(.link) button { + button.link:hover, + button.danger-link:hover { + text-decoration: underline; + } + + button.link, + button.danger-link { margin: 0; border: 0; appearance: none; background: none; - color: var(--link-color--); text-decoration: none; cursor: pointer; padding: 0; } - :host(.link.danger-link) button { - color: var(--color-danger--); + button.link { + color: var(--link-color--); } - :host(.link) button:hover { - text-decoration: underline; + button.danger-link { + color: var(--color-danger--); } .loading-indicator { diff --git a/src/themes/theme-styles.ts b/src/themes/theme-styles.ts index faaa5c8..a995d2b 100644 --- a/src/themes/theme-styles.ts +++ b/src/themes/theme-styles.ts @@ -17,6 +17,10 @@ const themeStyles = css` --default-combo-box-width: auto; --default-search-bar-width: auto; --default-search-bar-height: 30px; + --default-button-padding: 0 1.875rem; /* 0 30px with 16px root font size */ + --default-button-height: 2.25rem; /* 36px with 16px root font size */ + --default-button-width: fit-content; + --default-button-border-width: 1px; --default-font-size-standard: 0.875rem; /* 14px with 16px root font size */ --default-font-size-lg: 2.25rem; /* 36px with 16px root font size */ @@ -69,6 +73,19 @@ const themeStyles = css` --ia-theme-combo-box-width, var(--default-combo-box-width) ); + --button-padding: var( + --ia-theme-button-padding, + var(--default-button-padding) + ); + --button-height: var( + --ia-theme-button-height, + var(--default-button-height) + ); + --button-width: var(--ia-theme-button-width, var(--default-button-width)); + --button-border-width: var( + --ia-theme-button-border-width, + var(--default-button-border-width) + ); --font-size-standard: var( --ia-theme-font-size-standard, var(--default-font-size-standard) From b1dad38f4ca0d351f663a63e553a96c0e236b351 Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Wed, 27 May 2026 16:33:10 -0400 Subject: [PATCH 05/16] Add handling for custom active color --- src/elements/ia-button/ia-button.ts | 31 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index a585178..9b6add0 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -36,6 +36,9 @@ export class IAButton extends LitElement { | 'link' | 'danger-link' = 'primary'; + /* Whether to use a custom color (passed in via --ia-button-custom-fill etc.) when button is active */ + @property({ type: Boolean }) useCustomActiveColor: boolean = false; + /* Whether to show a loading indicator instead of the button */ @property({ type: Boolean }) loading: boolean = false; @@ -55,7 +58,8 @@ export class IAButton extends LitElement { return html` - + `; } From 56169c200d44c97acc522ca65bd6618b1cbaad86 Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Mon, 1 Jun 2026 14:30:28 -0400 Subject: [PATCH 14/16] Keep button text centered --- src/elements/ia-button/ia-button.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index 2c6ca94..1e82cad 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -265,6 +265,7 @@ export class IAButton extends LitElement { box-sizing: border-box; display: flex; align-items: center; + justify-content: center; vertical-align: middle; outline-offset: -4px; user-select: none; From 303e3ea78a5e0f50bc8cc1a6ed415c330ecae46e Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Mon, 1 Jun 2026 14:47:49 -0400 Subject: [PATCH 15/16] Simplify button emulation --- src/elements/ia-button/ia-button.ts | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/elements/ia-button/ia-button.ts b/src/elements/ia-button/ia-button.ts index 1e82cad..80d8f67 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -84,26 +84,14 @@ export class IAButton extends LitElement { /** Sets up or removes button type emulation as needed */ private setButtonTypeEmulation(): void { - this.removeExistingTypeEmulation(); - this.emulateButtonTypeBehavior(); - } - - /** - * Removes all existing button behavior emulation - * to ensure any new button type takes precendence. - */ - private removeExistingTypeEmulation(): void { - this.removeEventListener('click', this.handleComponentClick); - this.querySelector('.hidden-button')?.remove(); - } + const hiddenButton: HTMLInputElement | null = this.querySelector( + 'input.hidden-button', + ); - /** - * If the button type set to "submit" or "reset", - * adds extra behavior to ensure - * button emulates native button behavior. - * */ - private emulateButtonTypeBehavior(): void { - if (this.type === 'button') return; + if (hiddenButton) { + hiddenButton.type = this.type; + return; + } this.addHiddenButton(); this.addEventListener('click', this.handleComponentClick); From 1cddcd1f057e245f460cf29d9788d300baf2014f Mon Sep 17 00:00:00 2001 From: rebecca shoptaw Date: Tue, 2 Jun 2026 15:02:35 -0400 Subject: [PATCH 16/16] Fix variable name --- src/elements/ia-button/ia-button-story.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/elements/ia-button/ia-button-story.ts b/src/elements/ia-button/ia-button-story.ts index 39180a2..35da5e2 100644 --- a/src/elements/ia-button/ia-button-story.ts +++ b/src/elements/ia-button/ia-button-story.ts @@ -196,7 +196,7 @@ const styleInputSettings: StyleInputSettings[] = [ }, { label: 'Border color (custom)', - cssVariable: '--ia-theme-custom-cta-border', + cssVariable: '--ia-button-custom-border', defaultValue: '#c5d1df', inputType: 'color', },