Skip to content
This repository has been archived by the owner on Mar 27, 2023. It is now read-only.

Commit

Permalink
fix(core:button): disabled/loading state
Browse files Browse the repository at this point in the history
- update to initial disabled state after loading state has updated

Signed-off-by: Cory Rylan <splintercode.cb@gmail.com>
  • Loading branch information
coryrylan committed Jul 26, 2021
1 parent 979d0f5 commit 5058c08
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 158 deletions.
18 changes: 3 additions & 15 deletions packages/core/src/button/base-button.element.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
cursor: pointer;
font-size: var(--font-size);
height: 100%;
justify-content: center;
line-height: 1em;
min-width: var(--min-width);
overflow: visible;
Expand All @@ -52,21 +51,10 @@
@include center-content('block');
height: 100%;
}
}

.button-status-icon {
cds-icon {
@include min-equilateral(#{$cds-global-space-8});
fill: var(--color);
}

cds-icon[shape='error-standard'] {
--color: #{$cds-alias-status-danger};
}

.button-spinner {
--ring-color: #{$cds-global-color-construction-400};
}
}
cds-progress-circle {
--ring-color: #{$cds-global-color-construction-400};
}

:host(:active) .private-host,
Expand Down
117 changes: 57 additions & 60 deletions packages/core/src/button/button.element.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,6 @@ describe('button element', () => {
});

describe('LoadingStateChange', () => {
it('should fallback to default state as expected', async () => {
await componentIsStable(component);
component.loadingState = null;
await componentIsStable(component);
expect(component.loadingState).toEqual(ClrLoadingState.default);
expect(component.hasAttribute('disabled')).toEqual(false);
expect(component.style.getPropertyValue('width')).toBe('');
});

it('should set default state as expected', async () => {
await componentIsStable(component);
component.loadingState = ClrLoadingState.default;
Expand Down Expand Up @@ -280,51 +271,74 @@ describe('button element', () => {
component.disabled = true;
component.loadingState = ClrLoadingState.default;
await componentIsStable(component);
expect(component.disabled).toBeTruthy();
});
});

describe('Button link', () => {
let testLinkElement: HTMLElement;
let anchor: HTMLAnchorElement;
let anchorButton: HTMLButtonElement;
component.loadingState = ClrLoadingState.loading;
await componentIsStable(component);
expect(component.disabled).toBeTruthy();

beforeEach(async () => {
testLinkElement = await createTestElement(html` <a href="about"><cds-button>About</cds-button></a> `);
component.loadingState = ClrLoadingState.success;
await componentIsStable(component);
expect(component.disabled).toBeTruthy();

anchor = testLinkElement.querySelector<HTMLAnchorElement>('a');
anchorButton = testLinkElement.querySelector<HTMLButtonElement>('cds-button');
component.loadingState = ClrLoadingState.default;
await componentIsStable(component);
expect(component.disabled).toBeTruthy();
});

afterEach(() => {
removeTestElement(testLinkElement);
it('should default to spinner size to "18"', async () => {
component.loadingState = ClrLoadingState.loading;
await componentIsStable(component);
expect(component.shadowRoot.querySelector<any>('cds-progress-circle').size).toBe('18');
});

it('should render a link properly', async () => {
await componentIsStable(anchorButton);
expect(anchor.style.lineHeight).toBe('0');
expect(anchor.style.textDecoration).toBe('none');
it('should set spinner size to "12" if button size "sm"', async () => {
component.loadingState = ClrLoadingState.loading;
component.size = 'sm';
await componentIsStable(component);
expect(component.shadowRoot.querySelector<any>('cds-progress-circle').size).toBe('12');
});
});
});

it('should set button to be readonly', async () => {
await componentIsStable(anchorButton);
expect(anchor.querySelector('cds-button').readonly).toBe(true);
});
describe('Button link', () => {
let testLinkElement: HTMLElement;
let anchor: HTMLAnchorElement;
let anchorButton: HTMLButtonElement;

it('should not trigger button click if link', async () => {
await componentIsStable(anchorButton);
const o = {
f: () => {
// Do nothing
},
};
spyOn(o, 'f');
anchorButton.addEventListener('click', o.f);
anchor.focus();
anchor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
beforeEach(async () => {
testLinkElement = await createTestElement(html` <a href="about"><cds-button>About</cds-button></a> `);
anchor = testLinkElement.querySelector<HTMLAnchorElement>('a');
anchorButton = testLinkElement.querySelector<HTMLButtonElement>('cds-button');
});

expect(o.f).not.toHaveBeenCalled();
});
afterEach(() => {
removeTestElement(testLinkElement);
});

it('should render a link properly', async () => {
await componentIsStable(anchorButton);
expect(anchor.style.lineHeight).toBe('0');
expect(anchor.style.textDecoration).toBe('none');
});

it('should set button to be readonly', async () => {
await componentIsStable(anchorButton);
expect(anchor.querySelector('cds-button').readonly).toBe(true);
});

it('should not trigger button click if link', async () => {
await componentIsStable(anchorButton);
const o = {
f: () => {
// Do nothing
},
};
spyOn(o, 'f');
anchorButton.addEventListener('click', o.f);
anchor.focus();
anchor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));

expect(o.f).not.toHaveBeenCalled();
});
});

Expand All @@ -346,23 +360,6 @@ describe('buttonSlots: ', () => {
});
});

describe('iconSpinner(): ', () => {
it('should default to spinner size to "18"', () => {
const templateResult = iconSpinner('lg');
expect(templateResult.values.indexOf('18') > -1).toBe(true);
});

it('should set spinner size to "18" if not passed size "sm"', () => {
const templateResult = iconSpinner('anything-at-all');
expect(templateResult.values.indexOf('18') > -1).toBe(true);
});

it('should set spinner size to "12" if size "sm"', () => {
const templateResult = iconSpinner('sm');
expect(templateResult.values.indexOf('12') > -1).toBe(true);
});
});

describe('button keyboard interaction: ', () => {
it('should add active attr on click', async () => {
const element = await createTestElement(html`<cds-button>Text slot</cds-button>`);
Expand Down
103 changes: 32 additions & 71 deletions packages/core/src/button/button.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,28 +4,11 @@
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { baseStyles, CdsBaseButton, getElementWidth, property, spanWrapper } from '@cds/core/internal';
import { ClarityIcons } from '@cds/core/icon/icon.service.js';
import { errorStandardIcon } from '@cds/core/icon/shapes/error-standard.js';
import { checkIcon } from '@cds/core/icon/shapes/check.js';
import { baseStyles, CdsBaseButton, getElementWidth, property } from '@cds/core/internal';
import { html } from 'lit';
import { query } from 'lit/decorators/query.js';
import baseButtonStyles from './base-button.element.scss';
import styles from './button.element.scss';

export const iconSpinner = (size: string) => {
const spinnerSize = size === 'sm' ? '12' : '18';
return html`<span class="button-status-icon" cds-layout="horizontal align:center"><cds-progress-circle class="button-spinner" size="${spinnerSize}" status="info"></cds-progress-circle></span></span>`;
};

export const iconCheck = html`<span class="button-status-icon" cds-layout="horizontal align:center"
><cds-icon shape="check" status="success" cds-layout="align:center"></cds-icon
></span>`;

export const iconError = html`<span class="button-status-icon" cds-layout="horizontal align:center"
><cds-icon shape="error-standard" cds-layout="align:center"></cds-icon
></span>`;

export const enum ClrLoadingState {
default = 'default',
loading = 'loading',
Expand Down Expand Up @@ -89,8 +72,6 @@ export class CdsButton extends CdsBaseButton {
@property({ type: Boolean })
block = false;

@query('.private-host') privateHost: HTMLElement;

/**
* @type {default | loading | success | error}
* Changes the button content based on the value passed.
Expand All @@ -100,75 +81,55 @@ export class CdsButton extends CdsBaseButton {
* - `success`: disables the button and shows a check mark inside the button; auto-triggers to change back to DEFAULT state after 1000 ms
* - `error`: shows the content of the button (in the context of application, this state is usually entered from a LOADING state. the application should show appropriate error message)
*/
@property({ type: String })
loadingState: keyof typeof ClrLoadingState = ClrLoadingState.default;

constructor() {
super();
ClarityIcons.addIcons(errorStandardIcon, checkIcon);
@property({ type: String }) get loadingState() {
return this._loadingState;
}

firstUpdated(props: Map<string, any>) {
super.firstUpdated(props);

// Find and wrap any text nodes into span elements
spanWrapper(this.childNodes);

if (this.loadingState !== ClrLoadingState.default) {
this.updateLoadingState();
set loadingState(value: keyof typeof ClrLoadingState) {
// track prior disabled state to set prior value after button is re-enabled from a loading state
if (this._loadingState === ClrLoadingState.default) {
this._disabled = this.disabled;
}
}

update(props: Map<string, any>) {
if (this.privateHost && props.has('loadingState')) {
this.updateLoadingState();
if (value === ClrLoadingState.default) {
this.enableButton();
} else {
this.disableButton();
}
super.update(props);

const oldValue = this._loadingState;
this._loadingState = value;
this.requestUpdate('loadingState', oldValue);
}

private _loadingState: keyof typeof ClrLoadingState = ClrLoadingState.default;
private _disabled = false;

render() {
const loadingState = this.loadingState;
return html`<div class="private-host">
<div cds-layout="horizontal gap:sm wrap:none align:center">
${loadingState === ClrLoadingState.success ? iconCheck : ''}
${loadingState === ClrLoadingState.error ? iconError : ''}
${loadingState === ClrLoadingState.loading ? iconSpinner(this.size) : ''}
${loadingState === ClrLoadingState.default
? html`<slot @slotchange=${() => spanWrapper(this.childNodes)}></slot>`
: ''}
</div>
return html`<div class="private-host" cds-layout="horizontal gap:sm wrap:none align:center">
${this.loadingState === ClrLoadingState.success
? html`<cds-icon shape="check" status="success" size="18"></cds-icon>`
: ''}
${this.loadingState === ClrLoadingState.error
? html`<cds-icon shape="error-standard" status="danger" size="18"></cds-icon>`
: ''}
${this.loadingState === ClrLoadingState.loading
? html`<cds-progress-circle .size=${this.size === 'sm' ? '12' : '18'} status="info"></cds-progress-circle>`
: ''}
${this.loadingState === ClrLoadingState.default ? html`<slot></slot>` : ''}
</div>`;
}

static styles = [baseStyles, baseButtonStyles, styles];

private updateLoadingState() {
if (this.disabled) {
return;
}
switch (this.loadingState) {
case ClrLoadingState.loading:
this.disableButton();
return;
case ClrLoadingState.success:
this.disableButton();
return;
case ClrLoadingState.error:
this.disableButton();
return;
default:
this.enableButton();
}
}

private disableButton() {
private async disableButton() {
await this.updateComplete;
this.style.width = getElementWidth(this);
this.disabled = true;
}

private enableButton() {
this.loadingState = ClrLoadingState.default;
this.style.removeProperty('width');
this.disabled = false;
this.disabled = this._disabled;
}
}
13 changes: 1 addition & 12 deletions packages/core/src/button/icon-button.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
*/

import { baseStyles, property } from '@cds/core/internal';
import { html } from 'lit';
import baseButtonStyles from './base-button.element.scss';
import styles from './icon-button.element.scss';
import { CdsButton, ClrLoadingState, iconCheck, iconSpinner } from './button.element.js';
import { CdsButton } from './button.element.js';

/**
* Icon buttons give applications a compact alternative to communicate action and direct user intent.
Expand Down Expand Up @@ -44,15 +43,5 @@ export class CdsIconButton extends CdsButton {
@property({ type: String, required: 'warning' })
ariaLabel: string;

render() {
return html`
<div class="private-host">
${this.loadingState === ClrLoadingState.loading ? iconSpinner(this.size) : ''}
${this.loadingState === ClrLoadingState.success ? iconCheck : ''}
${this.loadingState === ClrLoadingState.default ? html`<slot></slot>` : ''}
</div>
`;
}

static styles = [baseStyles, baseButtonStyles, styles];
}
5 changes: 5 additions & 0 deletions packages/core/src/button/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { registerElementSafely } from '@cds/core/internal';
import { CdsButton } from './button.element.js';
import { CdsIconButton } from './icon-button.element.js';
import { CdsInlineButton } from './inline-button.element.js';
import { ClarityIcons } from '@cds/core/icon/icon.service.js';
import { errorStandardIcon } from '@cds/core/icon/shapes/error-standard.js';
import { checkIcon } from '@cds/core/icon/shapes/check.js';

ClarityIcons.addIcons(errorStandardIcon, checkIcon);

registerElementSafely('cds-button', CdsButton);
registerElementSafely('cds-icon-button', CdsIconButton);
Expand Down

0 comments on commit 5058c08

Please sign in to comment.