diff --git a/pages/toggle_action_button.html b/pages/toggle_action_button.html new file mode 100644 index 00000000..3cc7ba42 --- /dev/null +++ b/pages/toggle_action_button.html @@ -0,0 +1,41 @@ + + + + + + Development - Toggle Action Button + + + + + + + + + +
+ +
+
+
+
+ +
+

Toggle Button

+
+ +
+ + diff --git a/src/Index.ts b/src/Index.ts index 8f0fd7af..1b6a0df0 100644 --- a/src/Index.ts +++ b/src/Index.ts @@ -1,6 +1,7 @@ // This entry point defines all the components that are included in the extensions. export { ActionButton } from './components/ActionButton/ActionButton'; +export { ToggleActionButton } from './components/ActionButton/ToggleActionButton'; export { AttachResult } from './components/AttachResult/AttachResult'; export { UserActivity } from './components/UserActions/UserActivity'; export { UserActions } from './components/UserActions/UserActions'; diff --git a/src/components/ActionButton/ActionButton.scss b/src/components/ActionButton/ActionButton.scss index 8ab42867..43c49a69 100644 --- a/src/components/ActionButton/ActionButton.scss +++ b/src/components/ActionButton/ActionButton.scss @@ -1,10 +1,7 @@ -@import '../../sass/Variables.scss'; - $primary-color-lightest: #ffffff; $primary-color-lightest-hover: whitesmoke; $primary-color-light: #e5e5e5; $primary-color-dark: #4a4a4a; -$accent-color: $calypso; $button-size: 36px; @@ -52,11 +49,11 @@ button.CoveoActionButton.coveo-actionbutton { .CoveoActionButton.coveo-actionbutton:hover, .CoveoActionButton.coveo-actionbutton:active { & { - color: $accent-color; + color: $primary-color-dark; background-color: $primary-color-lightest-hover; } .coveo-actionbutton_icon svg { - fill: $accent-color; + fill: $primary-color-dark; } } diff --git a/src/components/ActionButton/ActionButton.ts b/src/components/ActionButton/ActionButton.ts index 944940e8..e0945f17 100644 --- a/src/components/ActionButton/ActionButton.ts +++ b/src/components/ActionButton/ActionButton.ts @@ -1,9 +1,9 @@ import { Component, ComponentOptions, IResultsComponentBindings, Initialization } from 'coveo-search-ui'; export interface IActionButtonOptions { + icon?: string; title?: string; tooltip?: string; - icon?: string; click?: () => void; } @@ -91,6 +91,27 @@ export class ActionButton extends Component { } } + /** + * Updates the button icon. + * @param icon Markup of the SVG icon to set. + */ + public updateIcon(icon: string): void { + const iconElement = this.element.querySelector('.coveo-actionbutton_icon'); + if (iconElement && icon && icon != iconElement.innerHTML) { + iconElement.innerHTML = icon; + } + } + + /** + * Updates the button tooltip. + * @param tooltip The tooltip to set. + */ + public updateTooltip(tooltip: string): void { + if (tooltip && tooltip != this.element.title) { + this.element.title = tooltip; + } + } + protected render(): void { this.applyButtonStyles(); diff --git a/src/components/ActionButton/ToggleActionButton.scss b/src/components/ActionButton/ToggleActionButton.scss new file mode 100644 index 00000000..a01c2435 --- /dev/null +++ b/src/components/ActionButton/ToggleActionButton.scss @@ -0,0 +1,15 @@ +@import './ActionButton.scss'; +@import '../../sass/Variables.scss'; + +$activated-color: $calypso; + +button.CoveoActionButton.coveo-actionbutton.coveo-toggleactionbutton-activated { + background-color: $activated-color; + border-color: $activated-color; + + .coveo-actionbutton_icon { + svg { + fill: $primary-color-lightest; + } + } +} diff --git a/src/components/ActionButton/ToggleActionButton.ts b/src/components/ActionButton/ToggleActionButton.ts new file mode 100644 index 00000000..331e99a2 --- /dev/null +++ b/src/components/ActionButton/ToggleActionButton.ts @@ -0,0 +1,182 @@ +import { ComponentOptions, IResultsComponentBindings, Component, Initialization } from 'coveo-search-ui'; +import { ActionButton } from './ActionButton'; + +export interface IToggleActionButtonOptions { + activatedIcon: string; + activatedTooltip: string; + deactivatedIcon: string; + deactivatedTooltip: string; + click?: () => void; + activate?: () => void; + deactivate?: () => void; +} + +export class ToggleActionButton extends Component { + static ID = 'ToggleActionButton'; + static ACTIVATED_CLASS_NAME = 'coveo-toggleactionbutton-activated'; + + static options: IToggleActionButtonOptions = { + /** + * Specifies the button icon when the button is activated. + * + * Default is the empty string. + * + * For example, with this SVG markup: + * + * ```xml + * ... + * ``` + * + * The attribute would be set like this: + * + * ```html + * + * ``` + */ + activatedIcon: ComponentOptions.buildStringOption(), + + /** + * Specifies the button tooltip when the button is activated. + * + * Default is the empty string. + * + * ```html + * + * ``` + */ + activatedTooltip: ComponentOptions.buildStringOption(), + + /** + * Specifies the button SVG icon when the button is deactivated. + * Note: The SVG markup has to be HTML encoded when set using the HTML attributes. + * + * Default is the empty string. + * + * For example, with this SVG markup: + * + * ```xml + * ... + * ``` + * + * The attribute would be set like this: + * + * ```html + * + * ``` + */ + deactivatedIcon: ComponentOptions.buildStringOption(), + + /** + * Specifies the button tooltip text when the button is deactivated. + * + * Default is the empty string. + * + * ```html + * + * ``` + */ + deactivatedTooltip: ComponentOptions.buildStringOption(), + + /** + * Specifies the handler called when the button is clicked. + * + * Default is `null`. + * + * This option is set in JavaScript when initializing the component. + */ + click: ComponentOptions.buildCustomOption(s => null), + + /** + * Specifies the handler called when the button is activated. + * + * Default is `null`. + * + * This option is set in JavaScript when initializing the component. + */ + activate: ComponentOptions.buildCustomOption(s => null), + + /** + * Specifies the handler called when the button is deactivated. + * + * Default is `null`. + * + * This option is set in JavaScript when initializing the component. + */ + deactivate: ComponentOptions.buildCustomOption(s => null) + }; + + private _isActivated: boolean = false; + private innerActionButton: ActionButton; + + constructor(public element: HTMLElement, public options: IToggleActionButtonOptions, public bindings?: IResultsComponentBindings) { + super(element, ToggleActionButton.ID, bindings); + this.options = ComponentOptions.initComponentOptions(element, ToggleActionButton, options); + + this.createInnerButton(bindings); + } + + /** + * Indicates whether the toggle button is in the activated state. + */ + public isActivated(): boolean { + return this._isActivated; + } + + /** + * Sets the toggle button to the specified state. + * @param activated Whether the button is activated. + */ + public setActivated(activated: boolean): void { + if (activated !== this.isActivated()) { + this._isActivated = activated; + this.updateButton(); + + if (this._isActivated && this.options.activate) { + this.options.activate(); + } + if (!this._isActivated && this.options.deactivate) { + this.options.deactivate(); + } + } + } + + protected onClick(): void { + this.setActivated(!this.isActivated()); + + if (this.options.click) { + this.options.click(); + } + } + + private createInnerButton(bindings?: IResultsComponentBindings): void { + this.innerActionButton = new ActionButton( + this.element, + { + icon: this.options.deactivatedIcon, + tooltip: this.options.deactivatedTooltip, + click: () => this.onClick() + }, + bindings + ); + + this.updateButton(); + } + + private updateButton() { + if (this._isActivated) { + this.element.classList.add(ToggleActionButton.ACTIVATED_CLASS_NAME); + this.element.setAttribute('aria-pressed', 'true'); + + this.innerActionButton.updateIcon(this.options.activatedIcon); + this.innerActionButton.updateTooltip(this.options.activatedTooltip); + } else { + this.element.classList.remove(ToggleActionButton.ACTIVATED_CLASS_NAME); + this.element.setAttribute('aria-pressed', 'false'); + + this.innerActionButton.updateIcon(this.options.deactivatedIcon); + this.innerActionButton.updateTooltip(this.options.deactivatedTooltip); + } + } +} + +Initialization.registerAutoCreateComponent(ToggleActionButton); diff --git a/src/sass/Index.scss b/src/sass/Index.scss index 3fb1269d..999bc5d5 100644 --- a/src/sass/Index.scss +++ b/src/sass/Index.scss @@ -1,4 +1,5 @@ @import '../components/ActionButton/ActionButton.scss'; +@import '../components/ActionButton/ToggleActionButton.scss'; @import '../components/AttachResult/AttachResult.scss'; @import '../components/UserActions/UserActions.scss'; @import '../components/ViewedByCustomer/ViewedByCustomer.scss'; diff --git a/tests/components/ActionButton/ActionButton.spec.ts b/tests/components/ActionButton/ActionButton.spec.ts index 541868bb..41af615e 100644 --- a/tests/components/ActionButton/ActionButton.spec.ts +++ b/tests/components/ActionButton/ActionButton.spec.ts @@ -30,6 +30,27 @@ describe('ActionButton', () => { sandbox.restore(); }); + function createActionButton(options: IActionButtonOptions) { + const element = document.createElement('button'); + const componentSetup = Mock.advancedComponentSetup(ActionButton, new Mock.AdvancedComponentSetupOptions(element, options)); + return componentSetup.cmp; + } + + function setOption(optionName: string, optionValue: any) { + const dictOptions = options as { [key: string]: any }; + dictOptions[optionName] = optionValue; + } + + function assertIconsAreEqual(actualIcon: string, expectedIcon: string) { + const actualElement = document.createElement('span'); + actualElement.innerHTML = actualIcon; + + const expectedElement = document.createElement('span'); + expectedElement.innerHTML = expectedIcon; + + expect(actualElement.innerHTML).toEqual(expectedElement.innerHTML); + } + it('should not log warnings in the console', () => { expect(consoleWarnSpy.called).toBeFalse(); }); @@ -93,6 +114,24 @@ describe('ActionButton', () => { }); }); + describe('updateIcon', () => { + it('should update the button icon', () => { + testSubject.updateIcon(icons.duplicate); + + const iconChild = testSubject.element.querySelector('.coveo-actionbutton_icon'); + assertIconsAreEqual(iconChild.innerHTML, icons.duplicate); + }); + }); + + describe('updateTooltip', () => { + it('should update the button tooltip', () => { + const newTooltip = 'some new tooltip'; + testSubject.updateTooltip(newTooltip); + + expect(testSubject.element.title).toEqual(newTooltip); + }); + }); + [ { optionName: 'title', @@ -134,15 +173,4 @@ describe('ActionButton', () => { }); }); }); - - const createActionButton = (options: IActionButtonOptions) => { - const element = document.createElement('button'); - const componentSetup = Mock.advancedComponentSetup(ActionButton, new Mock.AdvancedComponentSetupOptions(element, options)); - return componentSetup.cmp; - }; - - const setOption = (optionName: string, optionValue: any) => { - const dictOptions = options as { [key: string]: any }; - dictOptions[optionName] = optionValue; - }; }); diff --git a/tests/components/ActionButton/ToggleActionButton.spec.ts b/tests/components/ActionButton/ToggleActionButton.spec.ts new file mode 100644 index 00000000..f89e09ba --- /dev/null +++ b/tests/components/ActionButton/ToggleActionButton.spec.ts @@ -0,0 +1,132 @@ +import { SinonSandbox, createSandbox, SinonSpy } from 'sinon'; +import { Mock } from 'coveo-search-ui-tests'; +import { IToggleActionButtonOptions, ToggleActionButton } from '../../../src/components/ActionButton/ToggleActionButton'; +import * as icons from '../../../src/utils/icons'; +import { ActionButton } from '../../../src/components/ActionButton/ActionButton'; + +describe('ToggleActionButton', () => { + let sandbox: SinonSandbox; + let options: IToggleActionButtonOptions; + let testSubject: ToggleActionButton; + + let clickSpy: SinonSpy; + let activateSpy: SinonSpy; + let deactivateSpy: SinonSpy; + let updateIconSpy: SinonSpy; + let updateTooltipSpy: SinonSpy; + + beforeAll(() => { + sandbox = createSandbox(); + + clickSpy = sandbox.spy(); + activateSpy = sandbox.spy(); + deactivateSpy = sandbox.spy(); + updateIconSpy = sandbox.spy(ActionButton.prototype, 'updateIcon'); + updateTooltipSpy = sandbox.spy(ActionButton.prototype, 'updateTooltip'); + }); + + beforeEach(() => { + options = { + activatedIcon: icons.duplicate, + activatedTooltip: 'activated tooltip', + deactivatedIcon: icons.copy, + deactivatedTooltip: 'tooltip', + click: clickSpy, + activate: activateSpy, + deactivate: deactivateSpy + }; + + testSubject = createToggleButton(options); + }); + + afterEach(() => { + sandbox.reset(); + }); + + function createToggleButton(options: IToggleActionButtonOptions) { + const element = document.createElement('button'); + const componentSetup = Mock.advancedComponentSetup( + ToggleActionButton, + new Mock.AdvancedComponentSetupOptions(element, options) + ); + return componentSetup.cmp; + } + + describe('clicking the button', () => { + beforeEach(() => { + Coveo.$$(testSubject.element).trigger('click'); + }); + + it('should call the click handler', () => { + expect(clickSpy.called).toBeTrue(); + }); + }); + + describe('activating the button', () => { + beforeEach(() => { + testSubject.setActivated(true); + }); + + it('should have the isActivated method return true', () => { + expect(testSubject.isActivated()).toBeTrue(); + }); + + it('should call the activate handler', () => { + expect(activateSpy.called).toBeTrue(); + }); + + it('should add the activated marker CSS class', () => { + const hasMarkerClass = testSubject.element.classList.contains(ToggleActionButton.ACTIVATED_CLASS_NAME); + expect(hasMarkerClass).toBeTrue(); + }); + + it('should set aria-pressed attribute to true', () => { + const attributeValue = testSubject.element.getAttribute('aria-pressed'); + + expect(attributeValue).toEqual('true'); + }); + + it('should update button with activated icon', () => { + expect(updateIconSpy.calledWith(options.activatedIcon)).toBeTrue(); + }); + + it('should update button with activated tooltip', () => { + expect(updateTooltipSpy.calledWith(options.activatedTooltip)).toBeTrue(); + }); + }); + + describe('deactivating the button', () => { + beforeEach(() => { + testSubject.setActivated(true); + sandbox.reset(); + + testSubject.setActivated(false); + }); + + it('should have the isActivated method return false', () => { + expect(testSubject.isActivated()).toBeFalse(); + }); + + it('should call the deactivate handler', () => { + expect(deactivateSpy.called).toBeTrue(); + }); + + it('should remove the activated marker CSS class', () => { + const hasMarkerClass = testSubject.element.classList.contains(ToggleActionButton.ACTIVATED_CLASS_NAME); + expect(hasMarkerClass).toBeFalse(); + }); + + it('should set aria-pressed attribute to false', () => { + const attributeValue = testSubject.element.getAttribute('aria-pressed'); + expect(attributeValue).toEqual('false'); + }); + + it('should update button with deactivated icon', () => { + expect(updateIconSpy.calledWith(options.deactivatedIcon)).toBeTrue(); + }); + + it('should update button with deactivated tooltip', () => { + expect(updateTooltipSpy.calledWith(options.deactivatedTooltip)).toBeTrue(); + }); + }); +});