diff --git a/src/elements/ia-button/ia-button-story.ts b/src/elements/ia-button/ia-button-story.ts index 0eeadef..35da5e2 100644 --- a/src/elements/ia-button/ia-button-story.ts +++ b/src/elements/ia-button/ia-button-story.ts @@ -1,24 +1,235 @@ import { html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; +import '@demo/story-template'; import type { StyleInputSettings } from '@demo/story-components/story-styles-settings'; +import type { PropInputSettings } from '@demo/story-components/story-prop-settings'; +import type { IAButton } from './ia-button'; import './ia-button'; -import '@demo/story-template'; + +const propInputSettings: PropInputSettings[] = [ + { + label: 'Mode', + propertyName: 'mode', + defaultValue: 'primary', + inputType: 'radio', + radioOptions: [ + 'primary', + 'secondary', + 'danger', + 'warning', + 'disabled', + 'transparent', + 'custom', + 'link', + 'danger-link', + ], + }, + { + label: 'Disabled', + propertyName: 'disabled', + defaultValue: false, + inputType: 'radio', + radioOptions: [true, false], + }, + { + label: 'Loading', + propertyName: 'loading', + defaultValue: false, + inputType: 'radio', + radioOptions: [true, false], + }, + { + label: 'Loading text', + propertyName: 'loadingText', + defaultValue: '', + inputType: 'text', + }, + { + label: 'Type', + propertyName: 'type', + defaultValue: 'button', + inputType: 'radio', + radioOptions: ['button', 'submit', 'reset'], + }, +]; const styleInputSettings: StyleInputSettings[] = [ { - label: 'Text Color (Primary)', + label: 'Button padding', + cssVariable: '--ia-theme-button-padding', + defaultValue: '0 1.875rem', + inputType: 'text', + }, + { + label: 'Button width', + cssVariable: '--ia-theme-button-width', + defaultValue: 'fit-content', + inputType: 'text', + }, + { + label: 'Button height', + cssVariable: '--ia-theme-button-height', + defaultValue: '2.25rem', + inputType: 'text', + }, + { + label: 'Button border width', + cssVariable: '--ia-theme-button-border-width', + defaultValue: '1px', + inputType: 'text', + }, + { + label: 'Font', + cssVariable: '--ia-theme-base-font-family', + defaultValue: "'Helvetica Neue', Helvetica, Arial, sans-serif", + inputType: 'text', + }, + { + label: 'Transition', + cssVariable: '--ia-button-transition', + defaultValue: 'all 0.1s ease 0s', + inputType: 'text', + }, + { + label: 'Text color (primary)', cssVariable: '--ia-theme-primary-cta-text-color', defaultValue: '#ffffff', inputType: 'color', }, { - label: 'Background Color (Primary)', + label: 'Background color (primary)', cssVariable: '--ia-theme-primary-cta-fill', defaultValue: '#194880', inputType: 'color', }, + { + label: 'Border color (primary)', + cssVariable: '--ia-theme-primary-cta-border', + defaultValue: '#c5d1df', + inputType: 'color', + }, + { + label: 'Text color (secondary)', + cssVariable: '--ia-theme-secondary-cta-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (secondary)', + cssVariable: '--ia-theme-secondary-cta-fill', + defaultValue: '#333333', + inputType: 'color', + }, + { + label: 'Border color (secondary)', + cssVariable: '--ia-theme-secondary-cta-border', + defaultValue: '#666666', + inputType: 'color', + }, + { + label: 'Text color (danger)', + cssVariable: '--ia-theme-danger-cta-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (danger)', + cssVariable: '--ia-theme-danger-cta-fill', + defaultValue: '#d9534f', + inputType: 'color', + }, + { + label: 'Border color (danger)', + cssVariable: '--ia-theme-danger-cta-border', + defaultValue: '#d43f3a', + inputType: 'color', + }, + { + label: 'Text color (warning)', + cssVariable: '--ia-theme-warning-cta-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (warning)', + cssVariable: '--ia-theme-warning-cta-fill', + defaultValue: '#ee8950', + inputType: 'color', + }, + { + label: 'Border color (warning)', + cssVariable: '--ia-theme-warning-cta-border', + defaultValue: '#ec7939', + inputType: 'color', + }, + { + label: 'Text color (disabled)', + cssVariable: '--ia-theme-disabled-cta-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (disabled)', + cssVariable: '--ia-theme-disabled-cta-fill', + defaultValue: '#666666', + inputType: 'color', + }, + { + label: 'Border color (disabled)', + cssVariable: '--ia-theme-disabled-cta-border', + defaultValue: '#999999', + inputType: 'color', + }, + { + label: 'Text color (custom)', + cssVariable: '--ia-button-custom-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (custom)', + cssVariable: '--ia-button-custom-fill', + defaultValue: '#194880', + inputType: 'color', + }, + { + label: 'Border color (custom)', + cssVariable: '--ia-button-custom-border', + defaultValue: '#c5d1df', + inputType: 'color', + }, + { + label: 'Text color (custom, on hover)', + cssVariable: '--ia-button-custom-active-text-color', + defaultValue: '#ffffff', + inputType: 'color', + }, + { + label: 'Background color (custom, on hover)', + cssVariable: '--ia-button-custom-active-fill', + defaultValue: '#194880', + inputType: 'color', + }, + { + label: 'Border color (custom, on hover)', + cssVariable: '--ia-button-custom-active-border', + defaultValue: '#c5d1df', + inputType: 'color', + }, + { + label: 'Link color', + cssVariable: '--ia-theme-link-color', + defaultValue: '#4b64ff', + inputType: 'color', + }, + { + label: 'Danger color', + cssVariable: '--ia-theme-color-danger', + defaultValue: '#e51c23', + inputType: 'color', + }, ]; @customElement('ia-button-story') @@ -30,12 +241,11 @@ export class IAButtonStory extends LitElement { elementClassName="IAButton" .defaultUsageProps=${`@click=\${() => alert('Button clicked!')}`} .styleInputData=${{ settings: styleInputSettings }} + .propInputData=${{ settings: propInputSettings }} > -
- alert('Button clicked!')} - >Click Me -
+ alert('Button clicked!')}> + Click Me + `; } 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..80d8f67 100644 --- a/src/elements/ia-button/ia-button.ts +++ b/src/elements/ia-button/ia-button.ts @@ -1,34 +1,372 @@ -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() { + /* Which version of the button to display */ + @property({ type: String }) mode: + | 'primary' + | 'secondary' + | 'danger' + | 'warning' + | 'disabled' + | 'transparent' + | 'custom' + | 'link' + | 'danger-link' = 'primary'; + + /* 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 { + const hiddenButton: HTMLInputElement | null = this.querySelector( + 'input.hidden-button', + ); + + if (hiddenButton) { + hiddenButton.type = this.type; + 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); + --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); + + --button-padding--: var(--button-padding); + --button-width--: var(--button-width); + --button-height--: var(--button-height); + --button-border-width--: var(--button-border-width); + --button-border-radius--: var(--button-border-radius); + --base-font-family--: var(--base-font-family); + + --ia-button-transition--: var( + --ia-button-transition, + all 0.1s ease 0s + ); + + --ia-button-custom-text-color--: var( + --ia-button-custom-text-color, + var(--primary-cta-text-color--) + ); + --ia-button-custom-fill--: var( + --ia-button-custom-fill, + var(--primary-cta-fill--) + ); + --ia-button-custom-border--: var( + --ia-button-custom-border, + var(--primary-cta-border--) + ); + --ia-button-custom-active-text-color--: var( + --ia-button-custom-active-text-color, + var(--ia-button-custom-text-color--) + ); + --ia-button-custom-active-fill--: var( + --ia-button-custom-active-fill, + var(--ia-button-custom-fill--) + ); + --ia-button-custom-active-border--: var( + --ia-button-custom-active-border, + var(--ia-button-custom-border--) + ); + + display: inline-block; /* keeps host sized to button */ } button { - padding: 8px 16px; - background-color: var(--primary-background-color--); - color: var(--primary-text-color--); + font-family: var(--base-font-family--); + font-size: var(--font-size-standard--); + height: var(--button-height--); + min-height: var(--button-height--); + width: var(--button-width--); + padding: var(--button-padding--); + border-width: var(--button-border-width--); + border-radius: var(--button-border-radius--); + transition: var(--ia-button-transition--); + outline-color: var(--primary-cta-text-color--); + + cursor: pointer; + line-height: normal; + border-style: 'solid'; + white-space: nowrap; + appearance: auto; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + vertical-align: middle; + outline-offset: -4px; + user-select: none; + text-decoration: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + -o-user-select: none; + } + + button:disabled, + button.disabled { + cursor: not-allowed; + color: var(--disabled-cta-text-color--); + background-color: var(--disabled-cta-fill--); + border: 1px solid var(--disabled-cta-border--); + opacity: 0.5; + } + + button:enabled:hover { + opacity: 0.9; + } + + button:focus-visible { + opacity: 0.8; + outline-style: double; + } + + button:active { + opacity: 0.7; + } + + button.primary { + color: var(--primary-cta-text-color--); + background-color: var(--primary-cta-fill--); + border-color: var(--primary-cta-border--); + } + + button.secondary { + color: var(--secondary-cta-text-color--); + background-color: var(--secondary-cta-fill--); + border-color: var(--secondary-cta-border--); + } + + button.danger { + color: var(--danger-cta-text-color--); + background-color: var(--danger-cta-fill--); + border-color: var(--danger-cta-border--); + } + + button.warning { + color: var(--warning-cta-text-color--); + background-color: var(--warning-cta-fill--); + border-color: var(--warning-cta-border--); + } + + button.transparent { + color: inherit; + border-width: 0; + background-color: transparent; + border-color: transparent; + } + + button.custom { + color: var(--ia-button-custom-text-color--); + background-color: var(--ia-button-custom-fill--); + border-color: var(--ia-button-custom-border--); + } + + button.custom:enabled:is(:hover, :focus, :active) { + color: var(--ia-button-custom-active-text-color--); + background-color: var(--ia-button-custom-active-fill--); + border-color: var(--ia-button-custom-active-border--); + } + + :host(.fit-content) button { + padding: 0; + height: fit-content; + } + + button.link:enabled:hover, + button.danger-link:enabled:hover { + text-decoration: underline; + } + + button.link, + button.danger-link { + margin: 0; + border: 0; + appearance: none; + background: none; + text-decoration: none; + cursor: pointer; + padding: 0; + } + + button.link { + color: var(--link-color--); + } + + button.danger-link { + color: var(--color-danger--); + } + + .loading-indicator { + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; + } + + ia-status-indicator { + --ia-theme-primary-text-color: currentColor; } `, ]; diff --git a/src/themes/theme-styles.ts b/src/themes/theme-styles.ts index 34554df..44d8c58 100644 --- a/src/themes/theme-styles.ts +++ b/src/themes/theme-styles.ts @@ -17,6 +17,11 @@ 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-button-border-radius: 0.25rem; /* 4px with 16px root font size */ --default-font-size-standard: 0.875rem; /* 14px with 16px root font size */ --default-font-size-lg: 2.25rem; /* 36px with 16px root font size */ @@ -24,8 +29,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; @@ -62,6 +74,23 @@ 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) + ); + --button-border-radius: var( + --ia-theme-button-border-radius, + var(--default-button-border-radius) + ); --font-size-standard: var( --ia-theme-font-size-standard, var(--default-font-size-standard) @@ -79,11 +108,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)); }