Add Et2ButtonToggle & Et2SwitchIcon widgets

This commit is contained in:
nathan 2024-10-11 15:16:34 -06:00
parent 1bdf6ca2bb
commit 4fc96ee8b7
7 changed files with 553 additions and 0 deletions

View File

@ -0,0 +1,55 @@
```html:preview
<et2-button-toggle></et2-button-toggle>
```
:::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 buttons variant, same as regular buttons
```html:preview
<et2-button-toggle variant="default" label="Default"></et2-button-toggle>
<et2-button-toggle variant="primary" label="Primary"></et2-button-toggle>
<et2-button-toggle variant="success" label="Success"></et2-button-toggle>
<et2-button-toggle variant="neutral" label="Neutral"></et2-button-toggle>
<et2-button-toggle variant="warning" label="Warning"></et2-button-toggle>
<et2-button-toggle variant="danger" label="Danger"></et2-button-toggle>
```
### 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
<style>et2-button-toggle { --indicator-color: purple;}</style>
<et2-button-toggle></et2-button-toggle>
```
### Custom icon
Use the `icon` property to set the icon used
```html:preview
<et2-button-toggle icon="bell" ></et2-button-toggle>
```
### Custom icons
Use the `onIcon` and `offIcon` properties to customise what is displayed
```html:preview
<et2-button-toggle onIcon="bell" offIcon="bell-slash"></et2-button-toggle>
```

View File

@ -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(<HTMLElement>this.children[0]);
}
await this.updateComplete;
this.mutationObserver = new MutationObserver(this.handleIconChanged);
this.mutationObserver.observe(this, {subtree: true, childList: true});
}
willUpdate(changedProperties : PropertyValues<this>)
{
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 = <HTMLElement>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);
}
});
}
}
}

View File

@ -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<Et2ButtonToggle>(html`
<et2-button-toggle label="I'm a toggle button"></et2-button-toggle>
`);
// 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;
});
});

View File

@ -0,0 +1,24 @@
```html:preview
<et2-switch-icon></et2-switch-icon>
```
:::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
<et2-switch-icon onIcon="plus" offIcon="minus"></et2-switch-icon>
```

View File

@ -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 <SlSwitch>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`<span
part="form-control-label"
class="form-control__label">${this.label}
</span>` : nothing}
<span
class=${classMap({
"label": true,
"on": this.checked,
})}
aria-label="${this.label}"
>
<slot name="on">
<et2-image class="image on" src=${this.onIcon} title="${this.toggleOn}"></et2-image>
</slot>
<slot name="off">
<et2-image class="image off" src=${this.offIcon} title="${this.toggleOff}"></et2-image>
</slot>
</span>
`;
}
render()
{
return html`
<sl-switch
.label=${this.label}
.value=${live(this.value)}
.checked=${live(this.checked)}
.disabled=${live(this.disabled)}
.required=${this.required}
.helpText=${this.helpText}
>
${this.labelTemplate()}
</sl-switch>
`;
}
}

View File

@ -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<Et2SwitchIcon>(html`
<et2-switch-icon label="I'm a switch"></et2-switch-icon>
`);
// 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;
});
});

View File

@ -36,6 +36,7 @@ import './Et2Button/Et2ButtonCopy';
import './Et2Button/Et2ButtonIcon'; import './Et2Button/Et2ButtonIcon';
import './Et2Button/Et2ButtonScroll'; import './Et2Button/Et2ButtonScroll';
import './Et2Button/Et2ButtonTimestamper'; import './Et2Button/Et2ButtonTimestamper';
import './Et2Button/Et2ButtonToggle';
import './Et2Checkbox/Et2Checkbox'; import './Et2Checkbox/Et2Checkbox';
import './Et2Checkbox/Et2CheckboxReadonly'; import './Et2Checkbox/Et2CheckboxReadonly';
import './Et2Date/Et2Date'; import './Et2Date/Et2Date';
@ -84,6 +85,7 @@ import './Et2Select/Tag/Et2EmailTag';
import './Et2Select/Tag/Et2ThumbnailTag'; import './Et2Select/Tag/Et2ThumbnailTag';
import './Et2Spinner/Et2Spinner'; import './Et2Spinner/Et2Spinner';
import './Et2Switch/Et2Switch'; import './Et2Switch/Et2Switch';
import './Et2Switch/Et2SwitchIcon';
import './Et2Textarea/Et2Textarea'; import './Et2Textarea/Et2Textarea';
import './Et2Textarea/Et2TextareaReadonly'; import './Et2Textarea/Et2TextareaReadonly';
import './Et2Textbox/Et2Textbox'; import './Et2Textbox/Et2Textbox';