-
Notifications
You must be signed in to change notification settings - Fork 747
fix(core:button): disabled/loading state #6183
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,7 +5,7 @@ | |
*/ | ||
|
||
import { html } from 'lit'; | ||
import { CdsButton, ClrLoadingState, iconSpinner } from '@cds/core/button'; | ||
import { CdsButton, ClrLoadingState } from '@cds/core/button'; | ||
import '@cds/core/badge/register.js'; | ||
import '@cds/core/button/register.js'; | ||
import { componentIsStable, createTestElement, getComponentSlotContent, removeTestElement } from '@cds/core/test'; | ||
|
@@ -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; | ||
|
@@ -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', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved this describe since it was nested in the describe above which made it a bit confusing what component it was referencing. |
||
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(); | ||
}); | ||
}); | ||
|
||
|
@@ -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); | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved to the rest of the loading state specs |
||
}); | ||
|
||
describe('button keyboard interaction: ', () => { | ||
it('should add active attr on click', async () => { | ||
const element = await createTestElement(html`<cds-button>Text slot</cds-button>`); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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', | ||
|
@@ -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. | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
} | ||
} | ||
|
||
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>` : ''} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Layout was simplified and didn't need the wrapper elements anymore |
||
</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; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
|
@@ -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> | ||
`; | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Icon button was the same template/logic duplicated as the inherited template |
||
static styles = [baseStyles, baseButtonStyles, styles]; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moving the side effects to the register to follow the rest of the component register files |
||
|
||
registerElementSafely('cds-button', CdsButton); | ||
registerElementSafely('cds-icon-button', CdsIconButton); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was able to use the component properties and simplify the CSS