diff --git a/api/js/etemplate/Et2Button/Et2ButtonToggle.md b/api/js/etemplate/Et2Button/Et2ButtonToggle.md new file mode 100644 index 0000000000..8daea6504f --- /dev/null +++ b/api/js/etemplate/Et2Button/Et2ButtonToggle.md @@ -0,0 +1,55 @@ +```html:preview + +``` + +:::tip + +There are multiple components for dealing with boolean yes / no. + +* [ButtonToggle](../et2-button-toggle): This one +* [Checkbox](../et2-checkbox): Classic checkbox +* [Switch](../et2-switch): Switch to turn something on or off +* [SwitchIcon](../et2-switch-icon): Switch between two icons + +::: + +## Examples + +### Variants + +Use the variant attribute to set the button’s variant, same as regular buttons + +```html:preview + + + + + + +``` + +### Color + +Buttons are designed to have a uniform appearance, so their color is not inherited. However, you can still customize +them by setting `--indicator-color`. + +```html:preview + + +``` + +### Custom icon + +Use the `icon` property to set the icon used + +```html:preview + +``` + +### Custom icons + +Use the `onIcon` and `offIcon` properties to customise what is displayed + +```html:preview + +``` \ No newline at end of file diff --git a/api/js/etemplate/Et2Button/Et2ButtonToggle.ts b/api/js/etemplate/Et2Button/Et2ButtonToggle.ts new file mode 100644 index 0000000000..cfc0152340 --- /dev/null +++ b/api/js/etemplate/Et2Button/Et2ButtonToggle.ts @@ -0,0 +1,180 @@ +import {Et2SwitchIcon} from "../Et2Switch/Et2SwitchIcon"; +import {customElement} from "lit/decorators/custom-element.js"; +import {css, PropertyValues} from "lit"; +import {property} from "lit/decorators/property.js"; + +/** + * @summary A button to allow turning something on or off, displayed with two images instead of the normal button UI + * + * @slot - Add an image directly instead of setting the `icon` property + * @slot help-text - Text that describes how to use the button. Alternatively, you can use the `help-text` attribute. + * + * @cssproperty --indicator-color - The color of the selected image + */ +@customElement("et2-button-toggle") +export class Et2ButtonToggle extends Et2SwitchIcon +{ + static get styles() + { + return [ + ...super.styles, + css` + slot[name] { + display: none; + } + + sl-switch { + --width: var(--height); + } + + sl-switch:not([checked]) slot[name="off"] { + color: var(--sl-color-neutral-400); + } + + sl-switch[checked] slot[name="on"], sl-switch:not([checked]) slot[name="off"] { + display: inline-block; + } + + .label { + border: var(--sl-input-border-width) solid var(--sl-input-border-color); + border-radius: var(--sl-input-border-radius-medium); + } + + + /* Success */ + + :host([variant=success]) .label { + background-color: var(--sl-color-success-600); + border-color: var(--sl-color-success-600); + --indicator-color: var(--sl-color-neutral-0); + } + + :host([variant=success]) .label:hover { + background-color: var(--sl-color-success-500); + border-color: var(--sl-color-success-500); + --indicator-color: var(--sl-color-neutral-0); + } + + /* Neutral */ + + :host([variant=neutral]) .label { + background-color: var(--sl-color-neutral-600); + border-color: var(--sl-color-neutral-600); + --indicator-color: var(--sl-color-neutral-0); + } + + :host([variant=neutral]) .label:hover { + background-color: var(--sl-color-neutral-500); + border-color: var(--sl-color-neutral-500); + --indicator-color: var(--sl-color-neutral-0); + } + + /* Warning */ + + :host([variant=warning]) .label { + background-color: var(--sl-color-warning-600); + border-color: var(--sl-color-warning-600); + --indicator-color: var(--sl-color-neutral-0); + } + + :host([variant=warning]) .label:hover { + background-color: var(--sl-color-warning-500); + border-color: var(--sl-color-warning-500); + --indicator-color: var(--sl-color-neutral-0); + } + + /* Danger */ + + :host([variant=danger]) .label { + background-color: var(--sl-color-danger-600); + border-color: var(--sl-color-danger-600); + --indicator-color: var(--sl-color-neutral-0); + } + + :host([variant=danger]) .label:hover { + background-color: var(--sl-color-danger-500); + border-color: var(--sl-color-danger-500); + --indicator-color: var(--sl-color-neutral-0); + } + + ` + ] + } + + /** + * Name of the icon used. + * Alternatively, you can add an `et2-image` as a child + * @type {string} + */ + @property() icon = "check"; + + /** + * Specify the icon used when the toggle is off. Defaults to `icon` but dimmed. + * @type {string} + */ + @property() offIcon = "" + + /** + * + * @type {string} + */ + @property() variant = "neutral" + + private mutationObserver : MutationObserver; + + constructor() + { + super(); + + this.handleIconChanged = this.handleIconChanged.bind(this); + } + + async connectedCallback() + { + super.connectedCallback(); + + // If a child was added as part of loading, set up 1 child into both on/off slots + if(this.children && this.childElementCount == 1 && !this.children[0].hasAttribute("slot")) + { + this.adoptIcon(this.children[0]); + } + + await this.updateComplete; + + this.mutationObserver = new MutationObserver(this.handleIconChanged); + this.mutationObserver.observe(this, {subtree: true, childList: true}); + } + + willUpdate(changedProperties : PropertyValues) + { + if(changedProperties.has("icon") || this.icon && (!this.onIcon || this.onIcon == "check")) + { + this.onIcon = this.icon; + this.offIcon = this.icon; + } + } + + // Take a single element and give it the needed slots so it works + protected adoptIcon(icon : HTMLElement) + { + const off = icon.cloneNode(); + icon.setAttribute("slot", "on"); + off.setAttribute("slot", "off"); + this.append(off); + } + + // Listen for added icon and adopt it (needs to not have a slot) + protected handleIconChanged(mutations : MutationRecord[]) + { + for(const mutation of mutations) + { + mutation.addedNodes.forEach((n : HTMLElement) => + { + if(typeof n.hasAttribute == "function" && !n.hasAttribute("slot")) + { + this.adoptIcon(n); + } + }); + } + } +} \ No newline at end of file diff --git a/api/js/etemplate/Et2Button/test/Et2ButtonToggle.test.ts b/api/js/etemplate/Et2Button/test/Et2ButtonToggle.test.ts new file mode 100644 index 0000000000..0d487b2d1e --- /dev/null +++ b/api/js/etemplate/Et2Button/test/Et2ButtonToggle.test.ts @@ -0,0 +1,76 @@ +/** + * Test file for Etemplate webComponent Et2Switch + */ +import {assert, expect, fixture, html} from '@open-wc/testing'; +import * as sinon from 'sinon'; +import {Et2ButtonToggle} from "../Et2ButtonToggle"; + +describe("Toggle button widget", () => +{ + // Reference to component under test + let element : Et2ButtonToggle; + + + // Setup run before each test + beforeEach(async() => + { + // Create an element to test with, and wait until it's ready + element = await fixture(html` + + `); + + // Stub egw() + sinon.stub(element, "egw").returns({ + tooltipUnbind: () => {}, + // Image always give check mark. Use data URL to avoid having to serve an actual image + image: i => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=" + }); + }); + + // Make sure it works + it('is defined', () => + { + assert.instanceOf(element, Et2ButtonToggle); + }); + + it('has a label', () => + { + element.set_label("Label set"); + + assert.equal(element.textContent.trim(), "Label set"); + }) + + it("click happens", () => + { + // Setup + let clickSpy = sinon.spy(); + element.onclick = clickSpy; + + // Click + element.click(); + + // Check for once & only once + assert(clickSpy.calledOnce, "Click only once"); + }); + + + it("shows only 'on' icon when on", async() => + { + element.value = true; + const on = element.shadowRoot.querySelector(".label .on"); + const off = element.shadowRoot.querySelector(".label .off"); + expect(on, "Only 'on' icon should be visible").to.be.displayed; + // TODO: This takes a really long time, not sure why + //expect(off, "Only 'on' icon should be visible").not.to.be.displayed; + }); + + it("shows only 'off' icon when off", async() => + { + element.value = false; + const on = element.shadowRoot.querySelector(".label .on"); + const off = element.shadowRoot.querySelector(".label .off"); + expect(off, "Only 'off' icon should be visible").to.be.displayed; + // TODO: This takes a really long time, not sure why + //expect(on, "Only 'off' icon should be visible").not.to.be.displayed; + }); +}); \ No newline at end of file diff --git a/api/js/etemplate/Et2Switch/Et2SwitchIcon.md b/api/js/etemplate/Et2Switch/Et2SwitchIcon.md new file mode 100644 index 0000000000..d9bbf03c17 --- /dev/null +++ b/api/js/etemplate/Et2Switch/Et2SwitchIcon.md @@ -0,0 +1,24 @@ +```html:preview + +``` + +:::tip + +There are multiple components for dealing with boolean yes / no. + +* Et2SwitchIcon: This one +* [ButtonToggle](../et2-button-toggle): A button that shows an icon +* [Checkbox](../et2-checkbox): Classic checkbox +* [Switch](../et2-switch): Switch to turn something on or off + +::: + +## Examples + +### Custom icons + +Use the `onIcon` and `offIcon` properties to customise what is displayed + +```html:preview + +``` \ No newline at end of file diff --git a/api/js/etemplate/Et2Switch/Et2SwitchIcon.ts b/api/js/etemplate/Et2Switch/Et2SwitchIcon.ts new file mode 100644 index 0000000000..2dfb27961a --- /dev/null +++ b/api/js/etemplate/Et2Switch/Et2SwitchIcon.ts @@ -0,0 +1,145 @@ +import {css, html, LitElement, nothing} from "lit"; +import {customElement} from "lit/decorators/custom-element.js"; +import {property} from "lit/decorators/property.js"; +import {classMap} from "lit/directives/class-map.js"; +import {live} from "lit/directives/live.js"; +import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; +import {SlSwitch} from "@shoelace-style/shoelace"; + +/** + * @summary Switch to allow choosing between two options, displayed with two images + * + * @slot on - Content shown when the switch is on + * @slot off - Content shown when the switch is off + * @slot help-text - Text that describes how to use the switch. Alternatively, you can use the `help-text` attribute. + * + * @cssproperty --height - The height of the switch. + * @cssproperty --width - The width of the switch. + * @cssproperty --indicator-color - The color of the selected image + */ +@customElement("et2-switch-icon") +export class Et2SwitchIcon extends Et2InputWidget(LitElement) +{ + static get styles() + { + return [ + ...super.styles, + css` + :host { + --indicator-color: var(--sl-color-primary-600); + } + + sl-switch { + --sl-toggle-size-medium: 2em; + } + + ::part(control) { + display: none; + } + + ::part(label) { + width: 100%; + height: 100%; + } + + .label { + display: inline-flex; + flex: 1 1 auto; + font-size: var(--height); + user-select: none; + } + + et2-image, ::slotted(:scope > *) { + flex: 1 1 50%; + font-size: var(--width); + } + + slot { + color: var(--sl-color-neutral-400); + } + + sl-switch[checked] slot[name="on"], sl-switch:not([checked]) slot[name="off"] { + color: var(--indicator-color, inherit); + } + + .label:hover { + background-color: var(--sl-color-primary-50); + border-color: var(--sl-color-primary-300); + } + ` + ] + } + + /** + * Name of the icon displayed when the switch is on + * @type {string} + */ + @property() onIcon = "check"; + + /** + * Name of the icon displayed when the switch is off + * @type {string} + */ + @property() offIcon = "x" + + private get switch() : SlSwitch { return this.shadowRoot?.querySelector("sl-switch")}; + + private get input() { return this.switch.shadowRoot.querySelector("input");} + + async getUpdateComplete() + { + const result = await super.getUpdateComplete(); + await this.switch?.updateComplete; + return result; + } + + set value(new_value : string | boolean) + { + this.switch.checked = !!new_value; + } + + get value() + { + return this.switch?.checked; + } + + labelTemplate() + { + return html` + ${this.label ? html`${this.label} + ` : nothing} + + + + + + + + + `; + } + + render() + { + return html` + + ${this.labelTemplate()} + + `; + } +} \ No newline at end of file diff --git a/api/js/etemplate/Et2Switch/test/Et2SwitchIcon.test.ts b/api/js/etemplate/Et2Switch/test/Et2SwitchIcon.test.ts new file mode 100644 index 0000000000..909a311192 --- /dev/null +++ b/api/js/etemplate/Et2Switch/test/Et2SwitchIcon.test.ts @@ -0,0 +1,71 @@ +/** + * Test file for Etemplate webComponent Et2Switch + */ +import {assert, expect, fixture, html} from '@open-wc/testing'; +import * as sinon from 'sinon'; +import {Et2SwitchIcon} from "../Et2SwitchIcon"; + +describe("Switch icon widget", () => +{ + // Reference to component under test + let element : Et2SwitchIcon; + + + // Setup run before each test + beforeEach(async() => + { + // Create an element to test with, and wait until it's ready + element = await fixture(html` + + `); + + // Stub egw() + sinon.stub(element, "egw").returns({ + tooltipUnbind: () => {}, + // Image always give check mark. Use data URL to avoid having to serve an actual image + image: i => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=" + }); + }); + + // Make sure it works + it('is defined', () => + { + assert.instanceOf(element, Et2SwitchIcon); + }); + + it('has a label', () => + { + element.set_label("Label set"); + + assert.equal(element.textContent.trim(), "Label set"); + }) + + it("click happens", () => + { + // Setup + let clickSpy = sinon.spy(); + element.onclick = clickSpy; + + // Click + element.click(); + + // Check for once & only once + assert(clickSpy.calledOnce, "Click only once"); + }); + + it("shows 'on' icon", async() => + { + element.onIcon = "plus"; + await element.updateComplete; + const label = element.shadowRoot.querySelector(".label .on"); + expect(label).to.be.displayed; + }); + it("shows 'off' icon", async() => + { + element.offIcon = "minus"; + await element.updateComplete; + const label = element.shadowRoot.querySelector(".label .off"); + + expect(label).to.be.displayed; + }); +}); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index dfbdd4aed6..e06be19314 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -36,6 +36,7 @@ import './Et2Button/Et2ButtonCopy'; import './Et2Button/Et2ButtonIcon'; import './Et2Button/Et2ButtonScroll'; import './Et2Button/Et2ButtonTimestamper'; +import './Et2Button/Et2ButtonToggle'; import './Et2Checkbox/Et2Checkbox'; import './Et2Checkbox/Et2CheckboxReadonly'; import './Et2Date/Et2Date'; @@ -84,6 +85,7 @@ import './Et2Select/Tag/Et2EmailTag'; import './Et2Select/Tag/Et2ThumbnailTag'; import './Et2Spinner/Et2Spinner'; import './Et2Switch/Et2Switch'; +import './Et2Switch/Et2SwitchIcon'; import './Et2Textarea/Et2Textarea'; import './Et2Textarea/Et2TextareaReadonly'; import './Et2Textbox/Et2Textbox';