From 0e90d799ff3f9a82a70fd8c92b4bc5765d4a5446 Mon Sep 17 00:00:00 2001 From: Kieran Nichols Date: Tue, 12 Dec 2023 10:00:32 -0500 Subject: [PATCH] [@next] refactor button toggle (#442) --- src/dev/pages/button-toggle/button-toggle.ejs | 142 +++- .../pages/button-toggle/button-toggle.html | 18 +- .../pages/button-toggle/button-toggle.scss | 22 +- src/dev/pages/button-toggle/button-toggle.ts | 84 ++- src/lib/app-bar/app-bar/app-bar.test.ts | 8 - src/lib/button-toggle/build.json | 4 - .../button-toggle-group/_core.scss | 57 ++ .../button-toggle-group/_mixins.scss | 74 -- .../button-toggle-group/_token-utils.scss | 25 + .../button-toggle-group-adapter.ts | 133 +--- .../button-toggle-group-constants.ts | 29 +- .../button-toggle-group-foundation.ts | 210 +++--- .../button-toggle-group.html | 2 +- .../button-toggle-group.scss | 126 +++- .../button-toggle-group.ts | 174 ++++- .../button-toggle-group/index.scss | 3 + src/lib/button-toggle/button-toggle.test.ts | 588 ++++++++++++++++ .../button-toggle/button-toggle/_core.scss | 73 ++ .../button-toggle/button-toggle/_mixins.scss | 112 --- .../button-toggle/_token-utils.scss | 25 + .../button-toggle/button-toggle-adapter.ts | 79 +-- .../button-toggle/button-toggle-constants.ts | 28 +- .../button-toggle/button-toggle-foundation.ts | 141 ++-- .../button-toggle/button-toggle.html | 10 +- .../button-toggle/button-toggle.scss | 71 +- .../button-toggle/button-toggle.ts | 124 +++- .../button-toggle/button-toggle/index.scss | 1 + src/lib/calendar/calendar-foundation.ts | 3 +- .../base/base-form-associated-component.ts | 23 +- .../core/base/base-nullable-form-component.ts | 1 - src/lib/core/mixins/form/with-validity.ts | 73 ++ .../button-toggle-group/_tokens.scss | 43 ++ .../button-toggle/button-toggle/_tokens.scss | 62 ++ src/lib/core/utils/form-utils.ts | 2 +- src/lib/label/label-adapter.ts | 2 +- src/lib/label/label-constants.ts | 6 +- .../button-toggle/button-toggle.fixture.html | 5 - .../spec/button-toggle/button-toggle.spec.ts | 655 ------------------ src/test/spec/date-picker/date-picker.spec.ts | 3 +- 39 files changed, 1900 insertions(+), 1341 deletions(-) delete mode 100644 src/lib/button-toggle/build.json create mode 100644 src/lib/button-toggle/button-toggle-group/_core.scss delete mode 100644 src/lib/button-toggle/button-toggle-group/_mixins.scss create mode 100644 src/lib/button-toggle/button-toggle-group/_token-utils.scss create mode 100644 src/lib/button-toggle/button-toggle-group/index.scss create mode 100644 src/lib/button-toggle/button-toggle.test.ts create mode 100644 src/lib/button-toggle/button-toggle/_core.scss delete mode 100644 src/lib/button-toggle/button-toggle/_mixins.scss create mode 100644 src/lib/button-toggle/button-toggle/_token-utils.scss create mode 100644 src/lib/button-toggle/button-toggle/index.scss create mode 100644 src/lib/core/mixins/form/with-validity.ts create mode 100644 src/lib/core/styles/tokens/button-toggle/button-toggle-group/_tokens.scss create mode 100644 src/lib/core/styles/tokens/button-toggle/button-toggle/_tokens.scss delete mode 100644 src/test/spec/button-toggle/button-toggle.fixture.html delete mode 100644 src/test/spec/button-toggle/button-toggle.spec.ts diff --git a/src/dev/pages/button-toggle/button-toggle.ejs b/src/dev/pages/button-toggle/button-toggle.ejs index 21b17a383..0f427f013 100644 --- a/src/dev/pages/button-toggle/button-toggle.ejs +++ b/src/dev/pages/button-toggle/button-toggle.ejs @@ -1,17 +1,131 @@ -

Static

- - - - By email - - By mail - - By phone - - - +
+ +
+

Default

+ + + + By email + + + + By mail + + + + By phone + + +
-

Dynamic

- + +
+

No outline

+ + + + By email + + + + By mail + + + + By phone + + +
+ + +
+

w/Dividers

+ + + + By email + + + + + By mail + + + + + By phone + + +
+ + +
+

Form associated

+
+ + + + + By email + + + + + By mail + + + + + By phone + + + + Reset + Submit + + +
+
+ + +
+

Toolbar (custom)

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/src/dev/pages/button-toggle/button-toggle.html b/src/dev/pages/button-toggle/button-toggle.html index 0c1eaa6a2..83e3bacc0 100644 --- a/src/dev/pages/button-toggle/button-toggle.html +++ b/src/dev/pages/button-toggle/button-toggle.html @@ -4,12 +4,28 @@ title: 'Button toggle', includePath: './pages/button-toggle/button-toggle.ejs', options: [ + { + type: 'select', + label: 'Theme', + id: 'opt-theme', + defaultValue: 'primary', + options: [ + { label: 'Primary', value: 'primary' }, + { label: 'Secondary', value: 'secondary' }, + { label: 'Tertiary', value: 'tertiary' }, + { label: 'Success', value: 'success' }, + { label: 'Error', value: 'error' }, + { label: 'Warning', value: 'warning' }, + { label: 'Info', value: 'info' } + ] + }, { type: 'switch', label: 'Multiple', id: 'button-toggle-multiple' }, { type: 'switch', label: 'Mandatory', id: 'button-toggle-mandatory' }, { type: 'switch', label: 'Vertical', id: 'button-toggle-vertical' }, { type: 'switch', label: 'Stretch', id: 'button-toggle-stretch' }, { type: 'switch', label: 'Dense', id: 'button-toggle-dense' }, - { type: 'switch', label: 'Disabled', id: 'button-toggle-disabled' } + { type: 'switch', label: 'Disabled', id: 'button-toggle-disabled' }, + { type: 'switch', label: 'Readonly', id: 'button-toggle-readonly' } ] } }) diff --git a/src/dev/pages/button-toggle/button-toggle.scss b/src/dev/pages/button-toggle/button-toggle.scss index 10be6448c..6d4cc2d63 100644 --- a/src/dev/pages/button-toggle/button-toggle.scss +++ b/src/dev/pages/button-toggle/button-toggle.scss @@ -1,5 +1,19 @@ -@use '../../src/shared'; +@use '../../../lib/button-toggle/button-toggle-group'; -button { - width: 256px; -} +#toolbar-card { + display: inline-block; + --forge-card-padding: 0; + + @include button-toggle-group.provide-theme(( + padding: 4px + )); + + .toolbar-card__container { + display: flex; + } + + forge-divider { + height: auto; + margin-block: 4px; + } +} \ No newline at end of file diff --git a/src/dev/pages/button-toggle/button-toggle.ts b/src/dev/pages/button-toggle/button-toggle.ts index cb0604d71..f8c12564f 100644 --- a/src/dev/pages/button-toggle/button-toggle.ts +++ b/src/dev/pages/button-toggle/button-toggle.ts @@ -1,66 +1,102 @@ import '$src/shared'; import '@tylertech/forge/button-toggle'; import '@tylertech/forge/icon'; +import '@tylertech/forge/divider'; +import '@tylertech/forge/stack'; import { IconRegistry } from '@tylertech/forge/icon'; -import type { IButtonToggleGroupComponent } from '@tylertech/forge/button-toggle'; +import type { ButtonToggleGroupTheme, IButtonToggleGroupChangeEventData, IButtonToggleGroupComponent, IButtonToggleSelectEventData } from '@tylertech/forge/button-toggle'; +import type { ISelectComponent } from '@tylertech/forge/select'; import type { ISwitchComponent } from '@tylertech/forge/switch'; -import { tylIconEmail, tylIconFavorite, tylIconPerson, tylIconPhone, tylIconStar } from '@tylertech/tyler-icons/standard'; +import { + tylIconEmail, + tylIconMail, + tylIconFavorite, + tylIconFormatAlignCenter, + tylIconFormatAlignJustify, + tylIconFormatAlignLeft, + tylIconFormatAlignRight, + tylIconFormatBold, + tylIconFormatItalic, + tylIconFormatStrikethrough, + tylIconFormatUnderlined, + tylIconPerson, + tylIconPhone, + tylIconStar +} from '@tylertech/tyler-icons/standard'; +import './button-toggle.scss'; IconRegistry.define([ tylIconEmail, + tylIconMail, tylIconPhone, tylIconStar, tylIconFavorite, - tylIconPerson + tylIconPerson, + tylIconFormatBold, + tylIconFormatItalic, + tylIconFormatUnderlined, + tylIconFormatStrikethrough, + tylIconFormatAlignLeft, + tylIconFormatAlignCenter, + tylIconFormatAlignRight, + tylIconFormatAlignJustify ]); -const buttonToggleGroupStatic = document.querySelector('#button-toggle-group') as IButtonToggleGroupComponent; -const buttonToggleGroupDynamic = document.querySelector('#button-toggle-group-dynamic') as IButtonToggleGroupComponent; -buttonToggleGroupDynamic.options = [ - { label: 'Left', value: 'left', leadingIcon: 'star', leadingIconType: 'component' }, - { label: 'Middle', value: 'middle', leadingIcon: 'favorite', leadingIconType: 'component' }, - { label: 'Right', value: 'right', trailingIcon: 'person', trailingIconType: 'component' } -]; +function getButtonToggleGroups(): IButtonToggleGroupComponent[] { + return Array.from(document.querySelectorAll('forge-button-toggle-group')); +} -buttonToggleGroupStatic.addEventListener('forge-button-toggle-group-change', ({ detail }) => { +const buttonToggleDemoContainer = document.querySelector('.button-toggle-demo-container'); +buttonToggleDemoContainer.addEventListener('forge-button-toggle-group-change', ({ detail }: CustomEvent) => { console.log('[forge-button-toggle-group-change]', detail); }); -buttonToggleGroupStatic.addEventListener('forge-button-toggle-select', ({ detail }) => { +buttonToggleDemoContainer.addEventListener('forge-button-toggle-select', ({ detail }: CustomEvent) => { console.log('[forge-button-toggle-select]', detail); }); +const form = document.getElementById('button-toggle-form') as HTMLFormElement; +form.addEventListener('submit', (evt: Event) => { + evt.preventDefault(); + const formData = new FormData(form); + console.log('[form submit]', formData.getAll('my-button-toggle-group')); +}); + +const themeSelect = document.querySelector('#opt-theme') as ISelectComponent; +themeSelect.addEventListener('change', ({ detail: theme }: CustomEvent) => { + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.theme = theme); +}); + const multipleToggle = document.querySelector('#button-toggle-multiple') as ISwitchComponent; multipleToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.multiple = selected; - buttonToggleGroupDynamic.multiple = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.multiple = selected); }); const mandatoryToggle = document.querySelector('#button-toggle-mandatory') as ISwitchComponent; mandatoryToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.mandatory = selected; - buttonToggleGroupDynamic.mandatory = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.mandatory = selected); }); const verticalToggle = document.querySelector('#button-toggle-vertical') as ISwitchComponent; verticalToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.vertical = selected; - buttonToggleGroupDynamic.vertical = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.vertical = selected); }); const stretchToggle = document.querySelector('#button-toggle-stretch') as ISwitchComponent; stretchToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.stretch = selected; - buttonToggleGroupDynamic.stretch = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.stretch = selected); }); const denseToggle = document.querySelector('#button-toggle-dense') as ISwitchComponent; denseToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.dense = selected; - buttonToggleGroupDynamic.dense = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.dense = selected); }); const disabledToggle = document.querySelector('#button-toggle-disabled') as ISwitchComponent; disabledToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { - buttonToggleGroupStatic.disabled = selected; - buttonToggleGroupDynamic.disabled = selected; + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.disabled = selected); +}); + +const readonlyToggle = document.querySelector('#button-toggle-readonly') as ISwitchComponent; +readonlyToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + getButtonToggleGroups().forEach(buttonToggle => buttonToggle.readonly = selected); }); diff --git a/src/lib/app-bar/app-bar/app-bar.test.ts b/src/lib/app-bar/app-bar/app-bar.test.ts index 47c2255ca..c2fd505a3 100644 --- a/src/lib/app-bar/app-bar/app-bar.test.ts +++ b/src/lib/app-bar/app-bar/app-bar.test.ts @@ -144,12 +144,4 @@ describe('App Bar', () => { function getFocusIndicator(el: IAppBarComponent): IFocusIndicatorComponent { return el.shadowRoot?.querySelector('forge-focus-indicator') as IFocusIndicatorComponent; } - - function clickElement(el: HTMLElement): Promise { - const { x, y, width, height } = el.getBoundingClientRect(); - return sendMouse({ type: 'click', position: [ - Math.floor(x + window.scrollX + width / 2), - Math.floor(y + window.scrollY + height / 2), - ]}); - } }); diff --git a/src/lib/button-toggle/build.json b/src/lib/button-toggle/build.json deleted file mode 100644 index 07c79a465..000000000 --- a/src/lib/button-toggle/build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "$schema": "../../../node_modules/@tylertech/forge-cli/config/build-schema.json", - "extends": "../build.json" -} \ No newline at end of file diff --git a/src/lib/button-toggle/button-toggle-group/_core.scss b/src/lib/button-toggle/button-toggle-group/_core.scss new file mode 100644 index 000000000..f2229eeb3 --- /dev/null +++ b/src/lib/button-toggle/button-toggle-group/_core.scss @@ -0,0 +1,57 @@ +@use './token-utils' as *; + +@forward './token-utils'; + +@mixin host { + display: #{token(display)}; +} + +@mixin base { + display: flex; + align-items: center; + gap: #{token(gap)}; + + box-sizing: border-box; + height: #{token(height)}; + + border-width: #{token(outline-width)}; + border-style: #{token(outline-style)}; + border-color: #{token(outline-color)}; + border-start-start-radius: #{token(shape-start-start)}; + border-start-end-radius: #{token(shape-start-end)}; + border-end-start-radius: #{token(shape-end-start)}; + border-end-end-radius: #{token(shape-end-end)}; + padding-block: #{token(padding-block)}; + padding-inline: #{token(padding-inline)}; + + transition-property: border-color; + transition-duration: #{token(transition-duration)}; + transition-timing-function: #{token(transition-timing)}; +} + +@mixin active { + @include override(outline-color, outline-color-active); +} + +@mixin no-outline { + @include override(padding, 0, value); + @include override(outline-style, none, value); +} + +@mixin vertical { + height: auto; + flex-direction: column; +} + +@mixin host-stretch { + display: block; +} + +@mixin stretch { + width: 100%; +} + +@mixin dense { + @include override(padding, 0, value); + @include override(height, dense-height); +} diff --git a/src/lib/button-toggle/button-toggle-group/_mixins.scss b/src/lib/button-toggle/button-toggle-group/_mixins.scss deleted file mode 100644 index 091a80970..000000000 --- a/src/lib/button-toggle/button-toggle-group/_mixins.scss +++ /dev/null @@ -1,74 +0,0 @@ -@mixin host() { - display: block; -} - -@mixin core-styles() { - .forge-button-toggle-group { - @include base; - - ::slotted(:not(:last-child)) { - --forge-button-toggle-border-right-width: 0; - --forge-button-toggle-border-top-left-radius: 0; - --forge-button-toggle-border-bottom-left-radius: 0; - --forge-button-toggle-border-top-right-radius: 0; - --forge-button-toggle-border-bottom-right-radius: 0; - } - - ::slotted(:first-child) { - --forge-button-toggle-border-top-left-radius: 4px; - --forge-button-toggle-border-bottom-left-radius: 4px; - } - - ::slotted(:last-child) { - --forge-button-toggle-border-top-left-radius: 0; - --forge-button-toggle-border-bottom-left-radius: 0; - --forge-button-toggle-border-top-right-radius: 4px; - --forge-button-toggle-border-bottom-right-radius: 4px; - } - - &--vertical { - @include vertical; - - ::slotted(:not(:last-child)) { - --forge-button-toggle-border-right-width: 1px; - --forge-button-toggle-border-bottom-width: 0; - --forge-button-toggle-border-top-left-radius: 0; - --forge-button-toggle-border-bottom-left-radius: 0; - --forge-button-toggle-border-top-right-radius: 0; - --forge-button-toggle-border-bottom-right-radius: 0; - } - - ::slotted(:first-child) { - --forge-button-toggle-border-top-left-radius: 4px; - --forge-button-toggle-border-top-right-radius: 4px; - } - - ::slotted(:last-child) { - --forge-button-toggle-border-top-left-radius: 0; - --forge-button-toggle-border-top-right-radius: 0; - --forge-button-toggle-border-bottom-left-radius: 4px; - --forge-button-toggle-border-bottom-right-radius: 4px; - } - } - - // Keep this below the vertical class - ::slotted(:only-child) { - --forge-button-toggle-border-top-left-radius: 4px; - --forge-button-toggle-border-bottom-left-radius: 4px; - --forge-button-toggle-border-top-right-radius: 4px; - --forge-button-toggle-border-bottom-right-radius: 4px; - } - - &--stretch { - width: 100%; - } - } -} - -@mixin base() { - display: flex; -} - -@mixin vertical() { - flex-direction: column; -} diff --git a/src/lib/button-toggle/button-toggle-group/_token-utils.scss b/src/lib/button-toggle/button-toggle-group/_token-utils.scss new file mode 100644 index 000000000..1b7fdeca8 --- /dev/null +++ b/src/lib/button-toggle/button-toggle-group/_token-utils.scss @@ -0,0 +1,25 @@ +@use '../../core/styles/tokens/button-toggle/button-toggle-group/tokens'; +@use '../../core/styles/tokens/token-utils'; + +$_module: button-toggle-group; +$_tokens: tokens.$tokens; + +@mixin provide-theme($theme) { + @include token-utils.provide-theme($_module, $_tokens, $theme); +} + +@function token($name, $type: token) { + @return token-utils.token($_module, $_tokens, $name, $type); +} + +@function declare($token) { + @return token-utils.declare($_module, $token); +} + +@mixin override($token, $token-or-value, $type: token) { + @include token-utils.override($_module, $_tokens, $token, $token-or-value, $type); +} + +@mixin tokens($includes: null, $excludes: null) { + @include token-utils.tokens($_module, $_tokens, $includes, $excludes); +} diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group-adapter.ts b/src/lib/button-toggle/button-toggle-group/button-toggle-group-adapter.ts index 79f84ecba..ddaca1eb2 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group-adapter.ts +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group-adapter.ts @@ -1,10 +1,10 @@ -import { getShadowElement, removeAllChildren } from '@tylertech/forge-core'; -import { ICON_CLASS_NAME } from '../../constants'; +import { getShadowElement } from '@tylertech/forge-core'; +import { getFormState, getFormValue, getValidationMessage, internals, isFocusable } from '../../constants'; import { BaseAdapter, IBaseAdapter } from '../../core/base/base-adapter'; import { IButtonToggleComponent } from '../button-toggle/button-toggle'; import { BUTTON_TOGGLE_CONSTANTS } from '../button-toggle/button-toggle-constants'; import { IButtonToggleGroupComponent } from './button-toggle-group'; -import { BUTTON_TOGGLE_GROUP_CONSTANTS, IButtonToggleOption } from './button-toggle-group-constants'; +import { BUTTON_TOGGLE_GROUP_CONSTANTS } from './button-toggle-group-constants'; export interface IButtonToggleGroupAdapter extends IBaseAdapter { addListener(type: string, listener: (evt: Event) => void): void; @@ -12,24 +12,22 @@ export interface IButtonToggleGroupAdapter extends IBaseAdapter { addSlotChangeListener(listener: (evt: Event) => void): void; removeSlotChangeListener(listener: (evt: Event) => void): void; deselect(selectedToggle: IButtonToggleComponent): void; - applyAdjacentSelections(isVertical: boolean): void; - setVertical(isVertical: boolean): void; - setStretch(value: boolean): void; - setDense(value: boolean): void; setDisabled(value: boolean): void; + setReadonly(value: boolean): void; getSelectedValues(): any[]; applyValues(values: any[]): void; - createOptions(options: IButtonToggleOption[]): void; + setFormValue(): void; + setFormValidity(): void; } export class ButtonToggleGroupAdapter extends BaseAdapter implements IButtonToggleGroupAdapter { private _rootElement: HTMLElement; - private _slotElement: HTMLSlotElement; + private _defaultSlotElement: HTMLSlotElement; constructor(component: IButtonToggleGroupComponent) { super(component); this._rootElement = getShadowElement(component, BUTTON_TOGGLE_GROUP_CONSTANTS.selectors.ROOT); - this._slotElement = this._rootElement.querySelector('slot') as HTMLSlotElement; + this._defaultSlotElement = this._rootElement.querySelector('slot') as HTMLSlotElement; } public addListener(type: string, listener: (evt: Event) => void): void { @@ -41,10 +39,11 @@ export class ButtonToggleGroupAdapter extends BaseAdapter t !== selectedToggle).forEach(t => t.selected = false); } - public applyAdjacentSelections(isVertical: boolean): void { - const toggles = this._getButtonToggleElements(); - for (let i = toggles.length - 1; i > 0; i--) { - toggles[i].removeAttribute(BUTTON_TOGGLE_CONSTANTS.attributes.SELECTED_ADJACENT); - toggles[i].removeAttribute(BUTTON_TOGGLE_CONSTANTS.attributes.SELECTED_ADJACENT_VERTICAL); - if (toggles[i].selected && toggles[i - 1].selected) { - const attr = isVertical ? BUTTON_TOGGLE_CONSTANTS.attributes.SELECTED_ADJACENT_VERTICAL : BUTTON_TOGGLE_CONSTANTS.attributes.SELECTED_ADJACENT; - toggles[i].setAttribute(attr, ''); - } - } - } - - public setVertical(isVertical: boolean): void { - if (isVertical) { - this._rootElement.classList.add(BUTTON_TOGGLE_GROUP_CONSTANTS.classes.VERTICAL); - } else { - this._rootElement.classList.remove(BUTTON_TOGGLE_GROUP_CONSTANTS.classes.VERTICAL); - } - } - - public setStretch(value: boolean): void { - if (value) { - this._rootElement.classList.add(BUTTON_TOGGLE_GROUP_CONSTANTS.classes.STRETCH); - } else { - this._rootElement.classList.remove(BUTTON_TOGGLE_GROUP_CONSTANTS.classes.STRETCH); - } - - const toggles = this._getButtonToggleElements(); - toggles.forEach(toggle => { - if (value) { - toggle.setAttribute('stretch', ''); - } else { - toggle.removeAttribute('stretch'); - } - }); - } - - public setDense(value: boolean): void { + public setDisabled(value: boolean): void { + this._component[isFocusable] = !value; const toggles = this._getButtonToggleElements(); - toggles.forEach(t => t.dense = value); + toggles.forEach(t => t.disabled = value); } - public setDisabled(value: boolean): void { + public setReadonly(value: boolean): void { const toggles = this._getButtonToggleElements(); - toggles.forEach(t => t.disabled = value); + toggles.forEach(t => t.readonly = value); } public getSelectedValues(): any[] { @@ -109,58 +72,26 @@ export class ButtonToggleGroupAdapter extends BaseAdapter t.selected = values.indexOf(t.value) >= 0); } - public createOptions(options: IButtonToggleOption[]): void { - removeAllChildren(this._component); - options.forEach(o => { - this._component.appendChild(this._createButtonToggle(o)); - }); - } - - private _createButtonToggle(option: IButtonToggleOption): IButtonToggleComponent { - const buttonToggle = document.createElement(BUTTON_TOGGLE_CONSTANTS.elementName) as IButtonToggleComponent; - buttonToggle.value = option.value; - - if (option.label) { - buttonToggle.textContent = option.label; - } else if (option.icon) { - const icon = document.createElement('i'); - icon.textContent = option.icon; - icon.classList.add(ICON_CLASS_NAME); - icon.setAttribute('aria-hidden', 'true'); - buttonToggle.appendChild(icon); + public setFormValue(): void { + if (!this._component.form) { + return; } + const data = this._component[getFormValue](); + const state = this._component[getFormState](); + this._component[internals].setFormValue(data, state); + } - if (option.leadingIcon) { - let leadingIcon; - if (option.leadingIconType === 'component') { - leadingIcon = document.createElement('forge-icon'); - leadingIcon.name = option.leadingIcon; - } else { - leadingIcon = document.createElement('i'); - leadingIcon.textContent = option.leadingIcon; - leadingIcon.classList.add(ICON_CLASS_NAME); - leadingIcon.setAttribute('aria-hidden', 'true'); - } - leadingIcon.slot = 'leading'; - buttonToggle.appendChild(leadingIcon); + public setFormValidity(): void { + if (!this._component.form || !this._component.required) { + return; } - - if (option.trailingIcon) { - let trailingIcon; - if (option.trailingIconType === 'component') { - trailingIcon = document.createElement('forge-icon'); - trailingIcon.name = option.trailingIcon; - } else { - trailingIcon = document.createElement('i'); - trailingIcon.textContent = option.trailingIcon; - trailingIcon.classList.add(ICON_CLASS_NAME); - trailingIcon.setAttribute('aria-hidden', 'true'); - } - trailingIcon.slot = 'trailing'; - buttonToggle.appendChild(trailingIcon); + const required = this._component.multiple ? !this._component.value.length : !this._component.value; + if (required) { + const validationMessage = this._component[getValidationMessage]({ required }); + this._component[internals].setValidity({ valueMissing: required }, validationMessage, this._getButtonToggleElements()[0]); + } else { + this._component[internals].setValidity({}); } - - return buttonToggle; } private _getButtonToggleElements(): IButtonToggleComponent[] { diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group-constants.ts b/src/lib/button-toggle/button-toggle-group/button-toggle-group-constants.ts index 712600ff1..2fe3a5b88 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group-constants.ts +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group-constants.ts @@ -1,15 +1,23 @@ -import { COMPONENT_NAME_PREFIX } from '../../constants'; +import { COMPONENT_NAME_PREFIX, Theme } from '../../constants'; const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}button-toggle-group`; -const attributes = { +const observedAttributes = { VALUE: 'value', MULTIPLE: 'multiple', MANDATORY: 'mandatory', VERTICAL: 'vertical', STRETCH: 'stretch', DISABLED: 'disabled', - DENSE: 'dense' + REQUIRED: 'required', + READONLY: 'readonly', + DENSE: 'dense', + NO_OUTLINE: 'no-outline', + THEME: 'theme' +}; + +const attributes = { + ...observedAttributes }; const classes = { @@ -27,21 +35,12 @@ const events = { export const BUTTON_TOGGLE_GROUP_CONSTANTS = { elementName, + observedAttributes, attributes, classes, selectors, events }; -export type IButtonToggleGroupChangeEventData = T; -export type ButtonToggleIconType = 'font' | 'component'; - -export interface IButtonToggleOption { - label?: string; - icon?: string; - value: any; - leadingIcon?: string; - leadingIconType?: ButtonToggleIconType; - trailingIcon?: string; - trailingIconType?: ButtonToggleIconType; -} +export type IButtonToggleGroupChangeEventData = T; +export type ButtonToggleGroupTheme = Theme; diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group-foundation.ts b/src/lib/button-toggle/button-toggle-group/button-toggle-group-foundation.ts index a34990e93..0d55db642 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group-foundation.ts +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group-foundation.ts @@ -2,30 +2,35 @@ import { ICustomElementFoundation } from '@tylertech/forge-core'; import { IButtonToggleComponent } from '../button-toggle/button-toggle'; import { BUTTON_TOGGLE_CONSTANTS, IButtonToggleSelectEventData } from '../button-toggle/button-toggle-constants'; import { IButtonToggleGroupAdapter } from './button-toggle-group-adapter'; -import { BUTTON_TOGGLE_GROUP_CONSTANTS, IButtonToggleOption } from './button-toggle-group-constants'; +import { ButtonToggleGroupTheme, BUTTON_TOGGLE_GROUP_CONSTANTS, IButtonToggleGroupChangeEventData } from './button-toggle-group-constants'; export interface IButtonToggleGroupFoundation extends ICustomElementFoundation { - value: any; + value: unknown; multiple: boolean; stretch: boolean; mandatory: boolean; vertical: boolean; dense: boolean; disabled: boolean; - options: IButtonToggleOption[]; + readonly: boolean; + required: boolean; + outlined: boolean; + theme: ButtonToggleGroupTheme; } export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation { - private _isInitialized = false; + private _values: unknown[] = []; + private _outlined = true; private _multiple = false; private _mandatory = false; private _vertical = false; private _stretch = false; private _dense = false; private _disabled = false; - private _options: IButtonToggleOption[] = []; - private _values: any[] = []; - private _originalValue: any; + private _readonly = false; + private _required = false; + private _theme: ButtonToggleGroupTheme = 'primary'; + private _selectListener: (evt: CustomEvent) => void; private _slotListener: () => void; @@ -35,96 +40,88 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation } public initialize(): void { - if (this._options && this._options.length) { - this._applyOptions(false); - } - + this._adapter.setFormValue(); + this._adapter.setFormValidity(); this._adapter.addListener(BUTTON_TOGGLE_CONSTANTS.events.SELECT, this._selectListener); this._adapter.addSlotChangeListener(this._slotListener); - this._adapter.setVertical(this._vertical); - this._adapter.setStretch(this._stretch); - this._adapter.setDense(this._dense); - this._adapter.setDisabled(this._disabled); - this._adapter.applyAdjacentSelections(this._vertical); - this._isInitialized = true; } - public disconnect(): void { - this._isInitialized = false; + public destroy(): void { this._adapter.removeListener(BUTTON_TOGGLE_CONSTANTS.events.SELECT, this._selectListener); this._adapter.removeSlotChangeListener(this._slotListener); } private _synchronize(): void { - if (!this._multiple) { + if (this._disabled) { + this._adapter.setDisabled(this._disabled); + } + if (this._readonly) { + this._adapter.setReadonly(this._readonly); + } + + if (this._multiple) { + const selectedValues = new Set(this._adapter.getSelectedValues().concat(this._values)); + this.value = Array.from(selectedValues); + } else { const selectedValues = this._adapter.getSelectedValues().concat(this._values); this.value = selectedValues.length ? selectedValues[selectedValues.length - 1] : null; - } else { - const selectedValues = new Set(this._adapter.getSelectedValues().concat(this._values)); - this._applyValue(Array.from(selectedValues)); } } private _onSelect(evt: CustomEvent): void { - const target = evt.target as IButtonToggleComponent; - - // When in mandatory mode we need to ensure at least one element is selected. If there are no selections - // then we need to reselect the target toggle because it was deselected + // When in mandatory mode we need to ensure at least one element is selected. If the user tries to deselect the last + // element, we prevent the select event from toggling. if (this._mandatory) { const values = this._adapter.getSelectedValues(); if (!values.length) { - target.selected = true; + evt.preventDefault(); return; } } - // When not in multiple mode, we deselect all toggles, except for the one that was just changed - if (!this._multiple) { - this._adapter.deselect(target); + // Compute the new state to provide in the change event + let value: unknown[]; + if (evt.detail.selected) { + value = this._multiple ? [...this._values, evt.detail.value] : [evt.detail.value]; + } else { + value = this._multiple ? this._values.filter(v => v !== evt.detail.value) : []; } - this._adapter.applyAdjacentSelections(this._vertical); - this._adapter.emitHostEvent(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, this._getValue()); - } - - private _getValue(): any { - const selections = this._adapter.getSelectedValues(); - return this._multiple ? Array.from(new Set(selections)) : selections.slice(0, 1)[0] ?? null; - } + const detail: IButtonToggleGroupChangeEventData = this._multiple ? value : value.length ? value[0] : null; + const changeEvt = new CustomEvent(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, { detail, bubbles: true, cancelable: true }); + this._adapter.dispatchHostEvent(changeEvt); - private _applyValue(value: any): void { - let values = value instanceof Array ? value : [value]; - if (!this._multiple && values.length > 1) { - values = values[0]; - } - this._values = values; - this._adapter.applyValues(values); - if (this._multiple) { - this._adapter.applyAdjacentSelections(this._vertical); + if (changeEvt.defaultPrevented) { + evt.preventDefault(); + return; } + + this._values = value; + this._adapter.applyValues(this._values); + this._adapter.setFormValue(); + this._adapter.setFormValidity(); } - private _applyOptions(init = true): void { - if (this._options) { - this._adapter.createOptions(this._options); + private _applyValue(value: unknown[]): void { + let values = Array.isArray(value) ? value : value != null ? [value] : []; + this._values = values; - if (init) { - this._adapter.setStretch(this._stretch); - this._adapter.setDense(this._dense); - this._adapter.setDisabled(this._disabled); - this._adapter.applyAdjacentSelections(this._vertical); - } + if (!this._multiple && values.length > 1) { + values = [values[0]]; } + + this._adapter.applyValues(values); + this._adapter.setFormValue(); + this._adapter.setFormValidity(); } public get value(): any { - if (!this._isInitialized) { - return this._originalValue; - } - return this._getValue(); + // Combine the selected toggle values with our current state to ensure we always return the latest value + // even if our state doesn't match a selected toggle. + const values = Array.from(new Set(this._adapter.getSelectedValues().concat(this._values))); + return this._multiple ? Array.from(values) : values[0] ?? null; } public set value(value: any) { - this._originalValue = value; this._applyValue(value); } @@ -132,9 +129,11 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._multiple; } public set multiple(value: boolean) { + value = !!value; if (this._multiple !== value) { this._multiple = value; - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MULTIPLE, this._multiple as any); + this._applyValue(this._values); + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MULTIPLE, this._multiple); } } @@ -142,13 +141,10 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._mandatory; } public set mandatory(value: boolean) { + value = !!value; if (this._mandatory !== value) { this._mandatory = value; - if (this._mandatory) { - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MANDATORY); - } else { - this._adapter.removeHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MANDATORY); - } + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MANDATORY, this._mandatory); } } @@ -156,15 +152,10 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._vertical; } public set vertical(value: boolean) { + value = !!value; if (this._vertical !== value) { this._vertical = value; - this._adapter.setVertical(this._vertical); - this._adapter.applyAdjacentSelections(this._vertical); - if (this._vertical) { - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VERTICAL); - } else { - this._adapter.removeHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VERTICAL); - } + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VERTICAL, this._vertical); } } @@ -172,14 +163,10 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._stretch; } public set stretch(value: boolean) { + value = !!value; if (this._stretch !== value) { this._stretch = value; - this._adapter.setStretch(this._stretch); - if (this._stretch) { - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.STRETCH); - } else { - this._adapter.removeHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.STRETCH); - } + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.STRETCH, this._stretch); } } @@ -187,14 +174,10 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._dense; } public set dense(value: boolean) { + value = !!value; if (this._dense !== value) { this._dense = value; - this._adapter.setDense(this._dense); - if (this._dense) { - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DENSE); - } else { - this._adapter.removeHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DENSE); - } + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DENSE, this._dense); } } @@ -202,24 +185,55 @@ export class ButtonToggleGroupFoundation implements IButtonToggleGroupFoundation return this._disabled; } public set disabled(value: boolean) { + value = !!value; if (this._disabled !== value) { this._disabled = value; this._adapter.setDisabled(this._disabled); - if (this._disabled) { - this._adapter.setHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DISABLED); - } else { - this._adapter.removeHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DISABLED); - } + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DISABLED, this._disabled); + } + } + + public get readonly(): boolean { + return this._readonly; + } + public set readonly(value: boolean) { + value = !!value; + if (this._readonly !== value) { + this._readonly = value; + this._adapter.setReadonly(this._readonly); + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.READONLY, this._readonly); + } + } + + public get required(): boolean { + return this._required; + } + public set required(value: boolean) { + value = !!value; + if (this._required !== value) { + this._required = value; + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.REQUIRED, this._required); + } + } + + public get outlined(): boolean { + return this._outlined; + } + public set outlined(value: boolean) { + value = !!value; + if (this._outlined !== value) { + this._outlined = value; + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE, !this._outlined); } } - public get options(): IButtonToggleOption[] { - return this._options.map(o => ({ ...o })); + public get theme(): ButtonToggleGroupTheme { + return this._theme; } - public set options(value: IButtonToggleOption[]) { - this._options = value.map(o => ({ ...o })); - if (this._isInitialized) { - this._applyOptions(); + public set theme(value: ButtonToggleGroupTheme) { + if (this._theme !== value) { + this._theme = value; + this._adapter.toggleHostAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.THEME, this._theme !== 'primary', this._theme); } } } diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group.html b/src/lib/button-toggle/button-toggle-group/button-toggle-group.html index 4cdb1d75b..43b281507 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group.html +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group.html @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group.scss b/src/lib/button-toggle/button-toggle-group/button-toggle-group.scss index e956eeb05..fa1f9fcff 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group.scss +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group.scss @@ -1,11 +1,131 @@ -@use './mixins'; +@use './core' as *; +@use '../../core/styles/theme'; +@use '../button-toggle'; -@include mixins.core-styles; +// +// Host +// + +$_host-tokens: [display]; :host { - @include mixins.host; + @include tokens($includes: $_host-tokens); +} + +:host { + @include host; } :host([hidden]) { display: none; } + +// +// Base +// + +.forge-button-toggle-group { + @include tokens($excludes: $_host-tokens); +} + +.forge-button-toggle-group { + @include base; +} + +:host(:not([disabled]):not([no-outline])) { + .forge-button-toggle-group { + &:hover, + &:focus-within { + @include active; + } + } +} + +// +// No outline +// + +:host([no-outline]) { + .forge-button-toggle-group { + @include no-outline; + } +} + +// +// Density - Dense +// + +:host(:is([dense],[density=dense])) { + .forge-button-toggle-group { + @include dense; + } +} + +// +// Vertical +// + +:host([vertical]) { + .forge-button-toggle-group { + @include vertical; + } + + ::slotted(forge-button-toggle) { + height: #{token(height)}; + width: 100%; + } + + ::slotted(forge-divider:not([vertical])) { + margin-inline: 4px; + width: 100%; + } +} + +// +// Slotted - Divider +// + +::slotted(forge-divider[vertical]) { + margin-block: 4px; +} + +// +// Stretch +// + +:host([stretch]) { + @include host-stretch; + + .forge-button-toggle-group { + @include stretch; + } +} + +:host(:not([vertical])[stretch]) { + ::slotted(forge-button-toggle) { + flex: 1; + } +} + +// +// Theme +// + +@mixin theme($theme) { + :host([theme=#{$theme}]) { + ::slotted(forge-button-toggle) { + @include button-toggle.provide-theme(( + selected-background: #{theme.variable(#{$theme}-container-low)}, + selected-color: #{theme.variable($theme)}, + focus-indicator-color: #{theme.variable($theme)} + )); + } + } +} + +@include theme(secondary); +@include theme(tertiary); +@include theme(success); +@include theme(error); +@include theme(warning); +@include theme(info); diff --git a/src/lib/button-toggle/button-toggle-group/button-toggle-group.ts b/src/lib/button-toggle/button-toggle-group/button-toggle-group.ts index 7cd6922e0..c5ac21be1 100644 --- a/src/lib/button-toggle/button-toggle-group/button-toggle-group.ts +++ b/src/lib/button-toggle/button-toggle-group/button-toggle-group.ts @@ -1,22 +1,30 @@ import { attachShadowTemplate, coerceBoolean, CustomElement, FoundationProperty } from '@tylertech/forge-core'; -import { BaseComponent, IBaseComponent } from '../../core/base/base-component'; +import { IWithFormAssociation, WithFormAssociation } from '../../core/base/base-form-associated-component'; +import { IWithLabelAwareness, WithLabelAwareness } from '../../core/base/base-label-aware-component'; +import { IWithElementInternals, WithElementInternals } from '../../core/base/base-element-internals-component'; +import { IWithFormValidity, WithFormValidity } from '../../core/mixins/form/with-validity'; +import { BaseComponent } from '../../core/base/base-component'; import { ButtonToggleComponent } from '../button-toggle/button-toggle'; import { ButtonToggleGroupAdapter } from './button-toggle-group-adapter'; -import { BUTTON_TOGGLE_GROUP_CONSTANTS, IButtonToggleGroupChangeEventData, IButtonToggleOption } from './button-toggle-group-constants'; +import { ButtonToggleGroupTheme, BUTTON_TOGGLE_GROUP_CONSTANTS, IButtonToggleGroupChangeEventData } from './button-toggle-group-constants'; import { ButtonToggleGroupFoundation } from './button-toggle-group-foundation'; +import { getFormState, getFormValue, inputType, setDefaultAria } from '../../constants'; +import { FormValue, FormRestoreState, FormRestoreReason } from '../../core/utils/form-utils'; import template from './button-toggle-group.html'; import styles from './button-toggle-group.scss'; -export interface IButtonToggleGroupComponent extends IBaseComponent { +export interface IButtonToggleGroupComponent extends IWithLabelAwareness, IWithFormAssociation, IWithFormValidity, IWithElementInternals { value: any; + outlined: boolean; multiple: boolean; stretch: boolean; mandatory: boolean; vertical: boolean; disabled: boolean; + readonly: boolean; dense: boolean; - options: IButtonToggleOption[]; + theme: ButtonToggleGroupTheme; } declare global { @@ -29,26 +37,69 @@ declare global { } } +const BaseButtonToggleGroupClass = WithLabelAwareness(WithFormAssociation(WithFormValidity(WithElementInternals(BaseComponent)))); + /** - * The web component class behind the `` custom element. - * * @tag forge-button-toggle-group + * + * @description Button toggle groups allow users to select one or more options from a set of related options. + * + * @property {any} value - The value of the selected button toggle(s). + * @property {boolean} outlined - Whether or not the group should be outlined. + * @property {boolean} multiple - Whether or not the group should allow multiple selections. + * @property {boolean} stretch - Whether or not the group should stretch to fill the available width. + * @property {boolean} mandatory - Whether or not the group should require a selection once a button has been toggled on. + * @property {boolean} vertical - Whether or not the group should be displayed vertically. + * @property {boolean} disabled - Whether or not the group should be disabled. + * @property {boolean} readonly - Whether or not the group should be readonly. + * @property {boolean} dense - Whether or not the group should be dense. + * @property {ButtonToggleGroupTheme} theme - The theme to use for the group. + * + * @attribute {any} value - The value of the selected button toggle(s). + * @attribute {boolean} outlined - Whether or not the group should be outlined. + * @attribute {boolean} multiple - Whether or not the group should allow multiple selections. + * @attribute {boolean} stretch - Whether or not the group should stretch to fill the available width. + * @attribute {boolean} mandatory - Whether or not the group should require a selection once a button has been toggled on. + * @attribute {boolean} vertical - Whether or not the group should be displayed vertically. + * @attribute {boolean} disabled - Whether or not the group should be disabled. + * @attribute {boolean} readonly - Whether or not the group should be readonly. + * @attribute {boolean} dense - Whether or not the group should be dense. + * @attribute {ButtonToggleGroupTheme} theme - The theme to use for the group. + * + * @event {CustomEvent} forge-button-toggle-group-change - Dispatches when the value of the group changes. + * + * @cssproperty --forge-button-toggle-group-display - The `display` of the group container elements. + * @cssproperty --forge-button-toggle-group-gap - The space between button toggle elements. + * @cssproperty --forge-button-toggle-group-padding - The padding around the button toggle elements when outlined. + * @cssproperty --forge-button-toggle-group-padding-block - The block padding around the button toggle elements when outlined. + * @cssproperty --forge-button-toggle-group-padding-inline - The inline padding around the button toggle elements when outlined. + * @cssproperty --forge-button-toggle-group-height - The height of the group element. + * @cssproperty --forge-button-toggle-group-dense-height - The height of the group element when dense. + * @cssproperty --forge-button-toggle-group-outline-width - The width of the outline around the group element. + * @cssproperty --forge-button-toggle-group-outline-style - The style of the outline around the group element. + * @cssproperty --forge-button-toggle-group-outline-color - The color of the outline around the group element. + * @cssproperty --forge-button-toggle-group-outline-color-active - The color of the outline around the group element when hovered or focused. + * @cssproperty --forge-button-toggle-group-shape - The shape radius of the group container element. + * @cssproperty --forge-button-toggle-group-shape-start-start - The start-start shape radius. + * @cssproperty --forge-button-toggle-group-shape-start-end - The start-end shape radius. + * @cssproperty --forge-button-toggle-group-shape-end-start - The end-start shape radius. + * @cssproperty --forge-button-toggle-group-shape-end-end - The end-end shape radius. + * @cssproperty --forge-button-toggle-group-transition-duration - The transition duration for all animations on the group. + * @cssproperty --forge-button-toggle-group-transition-timing - The transition timing for all animations on the group. + * + * @csspart root - The root container element for the group. + * + * @slot - The is a default/unnamed slot for child button toggle elements. */ @CustomElement({ name: BUTTON_TOGGLE_GROUP_CONSTANTS.elementName, - dependencies: [ButtonToggleComponent] + dependencies: [ + ButtonToggleComponent + ] }) -export class ButtonToggleGroupComponent extends BaseComponent implements IButtonToggleGroupComponent { +export class ButtonToggleGroupComponent extends BaseButtonToggleGroupClass implements IButtonToggleGroupComponent { public static get observedAttributes(): string[] { - return [ - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VALUE, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MULTIPLE, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MANDATORY, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VERTICAL, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.STRETCH, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DISABLED, - BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DENSE - ]; + return Object.values(BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes); } private _foundation: ButtonToggleGroupFoundation; @@ -57,45 +108,108 @@ export class ButtonToggleGroupComponent extends BaseComponent implements IButton super(); attachShadowTemplate(this, template, styles); this._foundation = new ButtonToggleGroupFoundation(new ButtonToggleGroupAdapter(this)); + this[inputType] = 'radio'; // Used for form validity message to match radio button } - public initializedCallback(): void { + public connectedCallback(): void { + this[setDefaultAria]({ role: 'group' }); this._foundation.initialize(); } public disconnectedCallback(): void { - this._foundation.disconnect(); + this._foundation.destroy(); } public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { switch (name) { - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VALUE: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.VALUE: this.value = newValue; break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MULTIPLE: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.NO_OUTLINE: + this.outlined = !coerceBoolean(newValue); + break; + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.MULTIPLE: this.multiple = coerceBoolean(newValue); break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.MANDATORY: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.MANDATORY: this.mandatory = coerceBoolean(newValue); break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VERTICAL: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.VERTICAL: this.vertical = coerceBoolean(newValue); break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.STRETCH: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.STRETCH: this.stretch = coerceBoolean(newValue); break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DENSE: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.DENSE: this.dense = coerceBoolean(newValue); break; - case BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.DISABLED: + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.DISABLED: this.disabled = coerceBoolean(newValue); break; + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.REQUIRED: + this.required = coerceBoolean(newValue); + break; + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.READONLY: + this.readonly = coerceBoolean(newValue); + break; + case BUTTON_TOGGLE_GROUP_CONSTANTS.observedAttributes.THEME: + this.theme = newValue as ButtonToggleGroupTheme; + break; } } + public override [getFormValue](): FormValue | null { + const hasValue = Array.isArray(this.value) ? this.value.length > 0 : !!this.value; + let data: FormData | null = null; + if (hasValue) { + const value = Array.isArray(this.value) ? this.value : [this.value]; + if (value.length) { + data = new FormData(); + value.forEach(v => data?.append(this.name, v)); + } + } + return data; + } + + public [getFormState](): FormValue | null { + const state = new FormData(); + const value = Array.isArray(this.value) ? this.value : this.value != null ? [this.value] : []; + + state.append('multiple', String(this.multiple)); + value.forEach(v => state.append('value', v)); + + return state; + } + + public formStateRestoreCallback(state: FormRestoreState, reason: FormRestoreReason): void { + if (reason === 'restore' && state instanceof FormData) { + const multiple = state.get('multiple') === 'true'; + const value = state.getAll('value'); + + if (multiple) { + this.multiple = multiple; + this.value = value; + return; + } + + this.value = value[0] ?? null; + } + } + + public formResetCallback(): void { + this.value = this.getAttribute('value'); + } + + public labelChangedCallback(value: string | null): void { + this[setDefaultAria]({ ariaLabel: value ?? undefined }); + } + @FoundationProperty() public declare value: any; + @FoundationProperty() + public declare outlined: boolean; + @FoundationProperty() public declare multiple: boolean; @@ -115,5 +229,11 @@ export class ButtonToggleGroupComponent extends BaseComponent implements IButton public declare disabled: boolean; @FoundationProperty() - public declare options: IButtonToggleOption[]; + public declare required: boolean; + + @FoundationProperty() + public declare readonly: boolean; + + @FoundationProperty() + public declare theme: ButtonToggleGroupTheme; } diff --git a/src/lib/button-toggle/button-toggle-group/index.scss b/src/lib/button-toggle/button-toggle-group/index.scss new file mode 100644 index 000000000..394d7958a --- /dev/null +++ b/src/lib/button-toggle/button-toggle-group/index.scss @@ -0,0 +1,3 @@ +// @forward './configuration'; +@forward './core'; +// @forward './token-utils' show provide-theme; diff --git a/src/lib/button-toggle/button-toggle.test.ts b/src/lib/button-toggle/button-toggle.test.ts new file mode 100644 index 000000000..71f2399f2 --- /dev/null +++ b/src/lib/button-toggle/button-toggle.test.ts @@ -0,0 +1,588 @@ +import { nothing } from 'lit'; +import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; +import { elementUpdated, fixture, html } from '@open-wc/testing'; +import { sendKeys, sendMouse } from '@web/test-runner-commands'; +import { TestHarness } from '../../test/utils/test-harness'; +import { IButtonToggleGroupComponent } from './button-toggle-group/button-toggle-group'; +import { IButtonToggleComponent } from './button-toggle/button-toggle'; +import { BUTTON_TOGGLE_CONSTANTS } from './button-toggle/button-toggle-constants'; +import { ButtonToggleGroupTheme, BUTTON_TOGGLE_GROUP_CONSTANTS } from './button-toggle-group'; +import { getFormState } from '../constants'; + +import './button-toggle-group/button-toggle-group'; +import '../label/label'; + +describe('Button Toggle', () => { + it('should be accessible', async () => { + const harness = await createFixture(); + + await expect(harness.element).to.be.accessible(); + expect(harness.element.getAttribute('role')).to.equal('group'); + expect(harness.buttonToggles.every(toggle => toggle.getAttribute('role') === 'button')).to.be.true; + expect(harness.buttonToggles.every(toggle => toggle.getAttribute('aria-pressed') === 'false')).to.be.true; + }); + + it('should not have value by default', async () => { + const harness = await createFixture(); + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles.every(toggle => toggle.selected)).to.be.false; + }); + + it('should set value', async () => { + const harness = await createFixture(); + + harness.element.value = 'two'; + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + }); + + it('should set default value', async () => { + const harness = await createFixture({ value: 'two' }); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + }); + + it('should set value via attribute', async () => { + const harness = await createFixture(); + + harness.element.setAttribute('value', 'two'); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + }); + + it('should remove value', async () => { + const harness = await createFixture({ value: 'two' }); + + expect(harness.element.value).to.equal('two'); + + harness.element.value = null; + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles.every(toggle => toggle.selected)).to.be.false; + expect(harness.buttonToggles.every(toggle => toggle.getAttribute('aria-pressed') === 'false')).to.be.true; + }); + + it('should set multiple values', async () => { + const harness = await createFixture({ multiple: true, value: ['one', 'three'] }); + + expect(harness.element.value).to.deep.equal(['one', 'three']); + expect(harness.buttonToggles[0].selected).to.be.true; + expect(harness.buttonToggles[0].getAttribute('aria-pressed')).to.equal('true'); + expect(harness.buttonToggles[2].selected).to.be.true; + expect(harness.buttonToggles[2].getAttribute('aria-pressed')).to.equal('true'); + }); + + /** + * Execute the same tests for all boolean state attributes. + */ + ['vertical', 'stretch', 'mandatory', 'disabled', 'required', 'readonly', 'dense'].forEach(attr => { + it(`should set ${attr}`, async () => { + const harness = await createFixture({ [attr]: true }); + + expect(harness.element[attr]).to.be.true; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes[attr.toUpperCase()])).to.be.true; + }); + + it(`should set ${attr} via attribute`, async () => { + const harness = await createFixture(); + + harness.element.setAttribute(attr, ''); + + expect(harness.element[attr]).to.be.true; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes[attr.toUpperCase()])).to.be.true; + + harness.element.removeAttribute(attr); + + expect(harness.element[attr]).to.be.false; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes[attr.toUpperCase()])).to.be.false; + }); + }); + + it('should set non-focusable when disabled', async () => { + const harness = await createFixture({ disabled: true }); + + expect(harness.element.disabled).to.be.true; + expect(harness.element.hasAttribute('disabled')).to.be.true; + expect(harness.buttonToggles.every(toggle => toggle.tabIndex === -1)).to.be.true; + }); + + it('should set readonly', async () => { + const harness = await createFixture({ readonly: true }); + + expect(harness.element.readonly).to.be.true; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.READONLY)).to.be.true; + expect(harness.buttonToggles.every(toggle => toggle.readonly)).to.be.true; + }); + + it('should not toggle when readonly', async () => { + const harness = await createFixture({ readonly: true }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(0); + await harness.selectToggleViaMouse(1); + await harness.selectToggleViaMouse(2); + + expect(harness.element.value).to.be.null; + expect(harness.buttonToggles.every(toggle => toggle.selected)).to.be.false; + expect(changeSpy).not.to.have.been.called; + }); + + it('should set outlined', async () => { + const harness = await createFixture({ outlined: false }); + + expect(harness.element.outlined).to.be.false; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE)).to.be.true; + }); + + it('should set outlined via attribute', async () => { + const harness = await createFixture(); + + harness.element.setAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE, ''); + + expect(harness.element.outlined).to.be.false; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE)).to.be.true; + + harness.element.removeAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE); + + expect(harness.element.outlined).to.be.true; + expect(harness.element.hasAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.NO_OUTLINE)).to.be.false; + }); + + it('should select button toggle when clicked', async () => { + const harness = await createFixture(); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(1); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0].detail).to.equal('two'); + }); + + it('should select button toggle when focused and space is pressed', async () => { + const harness = await createFixture(); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaKeyboard(1); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0].detail).to.equal('two'); + }); + + it('should select button toggle when click() is called', async () => { + const harness = await createFixture(); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + harness.buttonToggles[1].click(); + await elementUpdated(harness.element); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(harness.buttonToggles[1].getAttribute('aria-pressed')).to.equal('true'); + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0].detail).to.equal('two'); + }); + + it('should deselect button toggle when clicked', async () => { + const harness = await createFixture({ value: 'two' }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(1); + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles[1].selected).to.be.false; + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0].detail).to.equal(null); + }); + + it('should deselect button toggle when focused and space is pressed', async () => { + const harness = await createFixture({ value: 'two' }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaKeyboard(1); + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles[1].selected).to.be.false; + expect(changeSpy).to.have.been.calledOnce; + expect(changeSpy.firstCall.args[0].detail).to.equal(null); + }); + + it('should select multiple button toggles when clicked', async () => { + const harness = await createFixture({ multiple: true }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(0); + await harness.selectToggleViaMouse(2); + + expect(harness.element.value).to.deep.equal(['one', 'three']); + expect(harness.buttonToggles[0].selected).to.be.true; + expect(harness.buttonToggles[2].selected).to.be.true; + expect(changeSpy).to.have.been.calledTwice; + expect(changeSpy.firstCall.args[0].detail).to.deep.equal(['one']); + expect(changeSpy.secondCall.args[0].detail).to.deep.equal(['one', 'three']); + }); + + it('should select multiple button toggles when focused and space is pressed', async () => { + const harness = await createFixture({ multiple: true }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaKeyboard(0); + await harness.selectToggleViaKeyboard(2); + + expect(harness.element.value).to.deep.equal(['one', 'three']); + expect(harness.buttonToggles[0].selected).to.be.true; + expect(harness.buttonToggles[2].selected).to.be.true; + expect(changeSpy).to.have.been.calledTwice; + expect(changeSpy.firstCall.args[0].detail).to.deep.equal(['one']); + expect(changeSpy.secondCall.args[0].detail).to.deep.equal(['one', 'three']); + }); + + it('should not select button toggle when change event is cancelled from click', async () => { + const harness = await createFixture(); + const changeSpy = sinon.spy(evt => evt.preventDefault()); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(1); + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles[1].selected).to.be.false; + }); + + it('should not select button toggle when change event is cancelled from keyboard', async () => { + const harness = await createFixture(); + const changeSpy = sinon.spy(evt => evt.preventDefault()); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaKeyboard(1); + + expect(harness.element.value).to.equal(null); + expect(harness.buttonToggles[1].selected).to.be.false; + }); + + it('should not deselect last selected button toggle when mandatory is set', async () => { + const harness = await createFixture({ value: 'two', mandatory: true }); + const changeSpy = sinon.spy(); + harness.element.addEventListener(BUTTON_TOGGLE_GROUP_CONSTANTS.events.CHANGE, changeSpy); + + await harness.selectToggleViaMouse(1); + + expect(harness.element.value).to.equal('two'); + expect(harness.buttonToggles[1].selected).to.be.true; + expect(changeSpy).not.to.have.been.called; + }); + + it('should associate parent form', async () => { + const harness = await createFixtureWithForm(); + + expect(harness.element.form).to.be.an.instanceOf(HTMLFormElement); + }); + + it('should have name', async () => { + const harness = await createFixtureWithForm(); + + expect(harness.element.name).to.equal('my-test-button-toggle-group'); + expect(harness.element.form?.elements.namedItem('my-test-button-toggle-group')).to.equal(harness.element); + }); + + it('should set form value', async () => { + const harness = await createFixtureWithForm(); + + harness.element.value = 'two'; + + const formData = new FormData(harness.element.form as HTMLFormElement); + expect(formData.get('my-test-button-toggle-group')).to.equal('two'); + }); + + it('should set form value when multiple', async () => { + const harness = await createFixtureWithForm({ multiple: true }); + + harness.element.value = ['one', 'three']; + + const formData = new FormData(harness.element.form as HTMLFormElement); + expect(formData.getAll('my-test-button-toggle-group')).to.deep.equal(['one', 'three']); + }); + + it('should restore form state', async () => { + const harness = await createFixtureWithForm(); + + harness.element.value = 'two'; + + const state = harness.element[getFormState](); + + harness.element.value = null; + + expect(harness.element.value).to.be.null; + + harness.element.formStateRestoreCallback(state, 'restore'); + + expect(harness.element.value).to.deep.equal('two'); + }); + + it('should restore form state to null if no value is set', async () => { + const harness = await createFixtureWithForm(); + + const state = harness.element[getFormState](); + + expect(harness.element.value).to.be.null; + + harness.element.formStateRestoreCallback(state, 'restore'); + + expect(harness.element.value).to.be.null; + }); + + it('should restore form state when multiple', async () => { + const harness = await createFixtureWithForm({ multiple: true }); + + harness.element.value = ['one', 'three']; + + const state = harness.element[getFormState](); + + harness.element.value = null; + + expect(harness.element.value).to.deep.equal([]); + + harness.element.formStateRestoreCallback(state, 'restore'); + + expect(harness.element.value).to.deep.equal(['one', 'three']); + }); + + it('should set validity when required', async () => { + const harness = await createFixtureWithForm({ required: true }); + + expect(harness.element.required).to.be.true; + expect(harness.element.hasAttribute('required')).to.be.true; + expect(harness.element.willValidate).to.be.true; + expect(harness.element.validity.valueMissing).to.be.true; + expect(harness.element.validationMessage).not.to.be.empty; + + harness.element.value = 'one'; + + expect(harness.element.validity.valueMissing).to.be.false; + expect(harness.element.validationMessage).to.be.empty; + }); + + it('should set validity when required with multiple', async () => { + const harness = await createFixtureWithForm({ multiple: true, required: true }); + + expect(harness.element.required).to.be.true; + expect(harness.element.hasAttribute('required')).to.be.true; + expect(harness.element.willValidate).to.be.true; + expect(harness.element.validity.valueMissing).to.be.true; + expect(harness.element.validationMessage).not.to.be.empty; + + harness.element.value = ['one', 'two']; + + expect(harness.element.validity.valueMissing).to.be.false; + expect(harness.element.validationMessage).to.be.empty; + }); + + it('should not set validity when not required', async () => { + const harness = await createFixtureWithForm(); + + expect(harness.element.required).to.be.false; + expect(harness.element.willValidate).to.be.true; + expect(harness.element.validity.valueMissing).to.be.false; + expect(harness.element.validationMessage).to.be.empty; + }); + + it('should reset value when form is reset', async () => { + const harness = await createFixtureWithForm({ value: 'two' }); + + expect(harness.element.getAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VALUE)).to.equal('two'); + expect(harness.element.value).to.equal('two'); + + harness.element.value = 'one'; + + expect(harness.element.value).to.equal('one'); + expect(harness.element.getAttribute(BUTTON_TOGGLE_GROUP_CONSTANTS.attributes.VALUE)).to.equal('two'); + + harness.element.form?.reset(); + + expect(harness.element.value).to.equal('two'); + }); + + it('should set associate