Skip to content

Commit

Permalink
feat: Add new d2l-button-toggle component. (#5048)
Browse files Browse the repository at this point in the history
  • Loading branch information
dbatiste authored Oct 8, 2024
1 parent 972d42b commit ddef2b8
Show file tree
Hide file tree
Showing 33 changed files with 321 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ npm install @brightspace-ui/core
* [Alert](components/alert/): alert components for displaying important information
* [Breadcrumbs](components/breadcrumbs/): component to help users understand where they are within an application
* [Backdrop](components/backdrop/): component for displaying backdrop behind a target element
* [Buttons](components/button/): normal, primary, icon and subtle buttons
* [Buttons](components/button/): normal, primary, icon, subtle, and toggle buttons
* [Calendar](components/calendar/): calendar component
* [Card](components/card/): card components
* [Colors](components/colors/): color palette
Expand Down
31 changes: 31 additions & 0 deletions components/button/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,34 @@ The `d2l-button-icon` element can be used just like the native `button`, for ins
</d2l-button-icon>
```

## Toggle Button [d2l-button-toggle]

The `d2l-button-toggle` element is a container for buttons that toggle a `pressed` state. The component will automatically show or hide the buttons and manage focus based on the `pressed` state. Simply place a `d2l-button-icon` or `d2l-button-subtle` element in each of the `not-pressed` and `pressed` slots. Each button should describe the state and action the user can take.

<!-- docs: demo code properties name:d2l-button-toggle sandboxTitle:'Toggle Button' -->
```html
<script type="module">
import '@brightspace-ui/core/components/button/button-subtle.js';
import '@brightspace-ui/core/components/button/button-toggle.js';
</script>
<d2l-button-toggle pressed>
<d2l-button-subtle slot="not-pressed" icon="tier1:lock-unlock" text="Unlocked" description="Click to lock."></d2l-button-subtle>
<d2l-button-subtle slot="pressed" icon="tier1:lock-locked" text="Locked" description="Click to unlock."></d2l-button-subtle>
</d2l-button-toggle>
```

<!-- docs: start hidden content -->
### Properties

| Property | Type | Description |
|--|--|--|
| `pressed` | Boolean | Pressed state |

### Events

- `d2l-button-toggle-change`: dispatched when the `pressed` state changes
<!-- docs: end hidden content -->

## Add Button [d2l-button-add]

The `d2l-button-add` is for quickly adding new items at a specific location, such as when adding items to a curated list. Since the Add button is meant to be subtle, it should always be used in combination with more obvious methods to add items (like a menu or primary button).
Expand Down Expand Up @@ -220,6 +248,9 @@ Daylight buttons rely on standard button semantics to ensure a smooth experience
* For [Icon Buttons](#d2l-button-icon) where there is no visible label, `text` will be displayed in a tooltip
* If both `text` and `aria-label` are used, then `aria-label` will be used as the primary label while `text` will be used in a [tooltip](../../components/tooltip)

* [Toggle buttons](#d2l-button-toggle) should describe the current state and the action the user can perform. As such, `aria-pressed` should not be used on the buttons as per [W3C's Button Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/button/#:~:text=Alternatively%2C%20if%20the%20design%20were%20to%20call%20for%20the%20button%20label%20to%20change%20from%20%22Mute%22%20to%20%22Unmute%2C%22%20the%20aria%2Dpressed%20attribute%20would%20not%20be%20needed.).
* Example: "Unpinned, click to pin" and "Pinned, click to unpin"

* [Floating Buttons](#d2l-floating-buttons) maintain their position in the document's structure, despite sticking to the bottom of the viewport, so the tab order is unaffected and the effect is imperceptible to screen reader users
* Be cautious when using `always-float`, since screen magnifier users may find it difficult to locate the buttons at the bottom of a large viewport

Expand Down
97 changes: 97 additions & 0 deletions components/button/button-toggle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { css, html, LitElement } from 'lit';

/**
* A button container component for button toggles.
*/
class ButtonToggle extends LitElement {

static get properties() {
return {
/**
* Pressed state
* @type {boolean}
*/
pressed: { type: Boolean, reflect: true }
};
}

static get styles() {
return css`
:host {
display: inline-block;
}
:host([hidden]) {
display: none;
}
::slotted(:not(d2l-button-icon, d2l-button-subtle)),
:host slot[name="pressed"],
:host([pressed]) slot[name="not-pressed"] {
display: none;
}
:host slot[name="not-pressed"],
:host([pressed]) slot[name="pressed"] {
display: contents;
}
`;
}

constructor() {
super();
this.pressed = false;
}

firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
if (this._focusOnFirstRender) {
this._focusOnFirstRender = false;
this.focus();
}
}

render() {
return html`
<slot @click="${this._handleNotPressedClick}" name="not-pressed"></slot>
<slot @click="${this._handlePressedClick}" name="pressed"></slot>
`;
}

updated(changedProperties) {
super.updated(changedProperties);

if (changedProperties.get('pressed') === undefined) return;

/** Dispatched when the pressed state changes */
this.dispatchEvent(new CustomEvent('d2l-button-toggle-change'));
}

focus() {
if (!this.hasUpdated) {
this._focusOnFirstRender = true;
return;
}

const elem = this.shadowRoot.querySelector(this.pressed ? 'slot[name="pressed"]' : 'slot[name="not-pressed"]').assignedNodes()[0];
if (!elem) {
throw new Error('d2l-button-toggle: no button to focus');
}

elem.focus();
}

async _handleClick(pressed) {
this.pressed = pressed;
await this.updateComplete;
this.focus();
}

_handleNotPressedClick() {
this._handleClick(true);
}

_handlePressedClick() {
this._handleClick(false);
}

}

customElements.define('d2l-button-toggle', ButtonToggle);
60 changes: 60 additions & 0 deletions components/button/demo/button-toggle.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<link rel="stylesheet" href="../../demo/styles.css" type="text/css">
<script type="module">
import '../../demo/demo-page.js';
import '../button-icon.js';
import '../button-subtle.js';
import '../button-toggle.js';
</script>
</head>
<body unresolved>

<d2l-demo-page page-title="d2l-button-toggle">

<h2>Toggle Button (using d2l-button-icon)</h2>

<d2l-demo-snippet>
<template>
<d2l-button-toggle id="toggle-button-icon">
<d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon>
<d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon>
</d2l-button-toggle>
<script>
document.querySelector('#toggle-button-icon').addEventListener('d2l-button-toggle-change', e => console.log(e));
</script>
</template>
</d2l-demo-snippet>

<h2>Toggle Button (using d2l-button-subtle)</h2>

<d2l-demo-snippet>
<template>
<d2l-button-toggle id="toggle-button-subtle" pressed>
<d2l-button-subtle slot="not-pressed" icon="tier1:lock-unlock" text="Unlocked" description="Click to lock."></d2l-button-subtle>
<d2l-button-subtle slot="pressed" icon="tier1:lock-locked" text="Locked" description="Click to unlock."></d2l-button-subtle>
</d2l-button-toggle>
<script>
document.querySelector('#toggle-button-subtle').addEventListener('d2l-button-toggle-change', e => console.log(e));
</script>
</template>
</d2l-demo-snippet>

<h2>Toggle Button (disabled)</h2>

<d2l-demo-snippet>
<template>
<d2l-button-toggle pressed>
<d2l-button-subtle slot="not-pressed" disabled icon="tier1:subscribe-hollow" text="Not Subscribed" description="Click to subscribe."></d2l-button-subtle>
<d2l-button-subtle slot="pressed" disabled icon="tier1:subscribe-filled" text="Subscribed" description="Click to unsubscribe."></d2l-button-subtle>
</d2l-button-toggle>
</template>
</d2l-demo-snippet>

</d2l-demo-page>

</body>
</html>
26 changes: 26 additions & 0 deletions components/button/test/button-toggle.axe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import '../button-icon.js';
import '../button-toggle.js';
import { expect, fixture, html } from '@brightspace-ui/testing';

describe('d2l-button-toggle', () => {

const normalFixture = html`
<d2l-button-toggle>
<d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon>
<d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon>
</d2l-button-toggle>
`;

it('not pressed', async() => {
const el = await fixture(normalFixture);
await expect(el).to.be.accessible();
});

it('pressed', async() => {
const el = await fixture(normalFixture);
el.pressed = true;
await el.updateComplete;
await expect(el).to.be.accessible();
});

});
66 changes: 66 additions & 0 deletions components/button/test/button-toggle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import '../button-icon.js';
import '../button-toggle.js';
import { clickElem, expect, fixture, html, oneEvent, runConstructor } from '@brightspace-ui/testing';

describe('d2l-button-toggle', () => {

describe('constructor', () => {

it('should construct', () => {
runConstructor('d2l-button-toggle');
});

});

describe('events', () => {

it('dispatches d2l-button-toggle-change event not-pressed is clicked', async() => {
const el = await fixture(html`
<d2l-button-toggle>
<d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon>
<d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon>
</d2l-button-toggle>
`);
clickElem(el.querySelector('[slot="not-pressed"]'));
const e = await oneEvent(el, 'd2l-button-toggle-change');
expect(e.target.pressed).to.equal(true);
});

it('dispatches d2l-button-toggle-change event pressed is clicked', async() => {
const el = await fixture(html`
<d2l-button-toggle pressed>
<d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon>
<d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon>
</d2l-button-toggle>
`);
clickElem(el.querySelector('[slot="pressed"]'));
const e = await oneEvent(el, 'd2l-button-toggle-change');
expect(e.target.pressed).to.equal(false);
});

it('does not dispatch d2l-button-toggle-change event initially', async() => {
let dispatched = false;
const el = document.createElement('d2l-button-toggle');
el.addEventListener('d2l-button-toggle-change', () => dispatched = true);
document.body.appendChild(el);
await el.updateComplete;
expect(dispatched).to.equal(false);
});

it('does not dispatch d2l-button-toggle-change event if disabled buttons are clicked', async() => {
const el = await fixture(html`
<d2l-button-toggle>
<d2l-button-icon slot="not-pressed" disabled icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon>
<d2l-button-icon slot="pressed" disabled icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon>
</d2l-button-toggle>
`);
let dispatched = false;
el.addEventListener('d2l-button-toggle-change', () => dispatched = true);
await clickElem(el.querySelector('[slot="not-pressed"]'));
expect(el.pressed).to.equal(false);
expect(dispatched).to.be.false;
});

});

});
39 changes: 39 additions & 0 deletions components/button/test/button-toggle.vdiff.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import '../button-icon.js';
import '../button-subtle.js';
import '../button-toggle.js';
import { clickElem, expect, fixture, focusElem, hoverElem, html, sendKeysElem } from '@brightspace-ui/testing';

describe('button-toggle', () => {

[
{ category: 'button-icon', template: html`<d2l-button-toggle><d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon><d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon></d2l-button-toggle>` },
{ category: 'button-icon-pressed', template: html`<d2l-button-toggle pressed><d2l-button-icon slot="not-pressed" icon="tier1:pin-hollow" text="Unpinned, click to pin."></d2l-button-icon><d2l-button-icon slot="pressed" icon="tier1:pin-filled" text="Pinned, click to unpin."></d2l-button-icon></d2l-button-toggle>` },
{ category: 'button-subtle', template: html`<d2l-button-toggle><d2l-button-subtle slot="not-pressed" icon="tier1:lock-unlock" text="Unlocked" description="Click to lock."></d2l-button-subtle><d2l-button-subtle slot="pressed" icon="tier1:lock-locked" text="Locked" description="Click to unlock."></d2l-button-subtle></d2l-button-toggle>` },
{ category: 'button-subtle-pressed', template: html`<d2l-button-toggle pressed><d2l-button-subtle slot="not-pressed" icon="tier1:lock-unlock" text="Unlocked" description="Click to lock."></d2l-button-subtle><d2l-button-subtle slot="pressed" icon="tier1:lock-locked" text="Locked" description="Click to unlock."></d2l-button-subtle></d2l-button-toggle>` },
{ category: 'button-subtle-disabled', template: html`<d2l-button-toggle><d2l-button-subtle slot="not-pressed" disabled icon="tier1:lock-unlock" text="Unlocked" description="Click to lock."></d2l-button-subtle><d2l-button-subtle slot="pressed" disabled icon="tier1:lock-locked" text="Locked" description="Click to unlock."></d2l-button-subtle></d2l-button-toggle>` }
].forEach(({ category, template }) => {

const getActiveButton = elem => {
if (elem.pressed) return elem.querySelector('[slot="pressed"]');
else return elem.querySelector('[slot="not-pressed"]');
};

describe(category, () => {
[
{ name: 'normal' },
{ name: 'hover', action: hoverElem },
{ name: 'focus', action: focusElem },
{ name: 'click', action: elem => clickElem(getActiveButton(elem)) },
{ name: 'enter', action: elem => sendKeysElem(getActiveButton(elem), 'press', 'Enter') }
].forEach(({ action, name }) => {
it(name, async() => {
const elem = await fixture(template);
if (action) await action(elem);
await expect(elem).to.be.golden();
});
});
});

});

});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ <h2 class="d2l-heading-3">Components</h2>
<li><a href="components/button/demo/button-add.html">d2l-button-add</a></li>
<li><a href="components/button/demo/button-icon.html">d2l-button-icon</a></li>
<li><a href="components/button/demo/button-subtle.html">d2l-button-subtle</a></li>
<li><a href="components/button/demo/button-toggle.html">d2l-button-toggle</a></li>
<li><a href="components/button/demo/floating-buttons.html">d2l-floating-buttons</a></li>
<li><a href="components/button/demo/floating-buttons-in-tabs.html">d2l-floating-buttons in tabs</a></li>
<li><a href="components/button/demo/floating-buttons-in-frame.html">d2l-floating-buttons in frame</a></li>
Expand Down

0 comments on commit ddef2b8

Please sign in to comment.