From f0395f43acd43b024955a601fd6839b30e1486a8 Mon Sep 17 00:00:00 2001 From: "Nichols, Kieran" Date: Wed, 15 Nov 2023 12:40:07 -0500 Subject: [PATCH] feat(icon-button): refactored icon-button --- package-lock.json | 37 - package.json | 1 - src/dev/index.ejs | 6 +- src/dev/pages/app-bar/app-bar.ejs | 6 +- src/dev/pages/autocomplete/autocomplete.ejs | 6 +- src/dev/pages/autocomplete/autocomplete.ts | 14 +- src/dev/pages/badge/badge.ejs | 12 +- src/dev/pages/button-area/button-area.ejs | 12 +- src/dev/pages/button/button.ejs | 90 +- src/dev/pages/button/button.ts | 1 + src/dev/pages/card/card.ejs | 6 +- src/dev/pages/chips/chips.ejs | 6 +- .../pages/dialog/dialog-template-basic.ejs | 6 +- src/dev/pages/icon-button/icon-button.ejs | 130 +- src/dev/pages/icon-button/icon-button.html | 44 +- src/dev/pages/icon-button/icon-button.scss | 5 + src/dev/pages/icon-button/icon-button.ts | 53 +- .../pages/quantity-field/quantity-field.ejs | 8 +- src/dev/pages/split-button/split-button.ejs | 10 +- src/dev/pages/split-button/split-button.html | 24 +- src/dev/pages/split-button/split-button.ts | 6 + src/dev/pages/table/table.ts | 13 +- src/dev/pages/text-field/text-field.ejs | 6 +- src/dev/pages/theme/theme.ts | 28 +- src/dev/src/partials/header.ejs | 8 +- src/lib/app-bar/_mixins.scss | 11 +- .../help-button/app-bar-help-button.html | 12 +- .../menu-button/app-bar-menu-button.html | 8 +- .../app-bar-notification-button.html | 8 +- .../app-bar-profile-button-constants.ts | 2 +- .../app-bar-profile-button.html | 8 +- src/lib/banner/banner-constants.ts | 2 +- src/lib/banner/banner.html | 8 +- src/lib/banner/banner.scss | 1 - src/lib/bottom-sheet/bottom-sheet.scss | 1 - src/lib/button/_core.scss | 34 +- src/lib/button/base/base-button-adapter.ts | 31 +- src/lib/button/base/base-button-constants.ts | 3 +- src/lib/button/base/base-button-foundation.ts | 22 +- src/lib/button/base/base-button.test.ts | 1151 +++++++++++++++++ src/lib/button/base/base-button.ts | 22 +- src/lib/button/button-constants.ts | 2 +- src/lib/button/button.html | 6 +- src/lib/button/button.scss | 45 +- src/lib/button/button.test.ts | 956 +------------- src/lib/button/button.ts | 12 +- src/lib/calendar/calendar-dom-utils.ts | 20 +- src/lib/calendar/calendar.scss | 3 - src/lib/checkbox/checkbox.ts | 2 +- .../circular-progress/circular-progress.scss | 6 +- src/lib/color-picker/color-picker.html | 8 +- src/lib/color-picker/color-picker.scss | 3 - src/lib/constants.ts | 8 + src/lib/core/base/base-component.ts | 64 - .../base/base-focusable-component.test.ts | 78 ++ src/lib/core/base/base-focusable-component.ts | 105 ++ src/lib/core/base/base-form-component.ts | 46 + .../core/base/base-nullable-form-component.ts | 37 + src/lib/core/styles/_utils.scss | 9 + .../core/styles/tokens/button/_tokens.scss | 43 +- .../styles/tokens/icon-button/_tokens.scss | 101 ++ .../styles/tokens/theme/_token-utils.scss | 12 +- .../base/base-date-picker-utils.ts | 14 +- src/lib/file-picker/_mixins.scss | 2 +- src/lib/focus-indicator/_core.scss | 2 +- src/lib/forge.scss | 1 - src/lib/icon-button/_configuration.scss | 11 + src/lib/icon-button/_core.scss | 140 ++ src/lib/icon-button/_mixins.scss | 340 ----- src/lib/icon-button/_token-utils.scss | 25 + src/lib/icon-button/_variables.scss | 37 - src/lib/icon-button/build.json | 5 +- src/lib/icon-button/forge-icon-button.scss | 3 - src/lib/icon-button/icon-button-adapter.ts | 10 + .../icon-button-component-delegate.ts | 29 +- src/lib/icon-button/icon-button-constants.ts | 53 +- src/lib/icon-button/icon-button-foundation.ts | 141 ++ src/lib/icon-button/icon-button.html | 10 + src/lib/icon-button/icon-button.scss | 310 +++++ src/lib/icon-button/icon-button.test.ts | 362 ++++++ src/lib/icon-button/icon-button.ts | 358 ++--- src/lib/icon-button/index.scss | 3 + src/lib/label/_core.scss | 1 + src/lib/label/label-adapter.ts | 1 + src/lib/label/label-constants.ts | 6 +- src/lib/label/label-foundation.ts | 2 +- src/lib/linear-progress/linear-progress.scss | 4 +- src/lib/paginator/paginator-constants.ts | 8 +- src/lib/paginator/paginator.html | 38 +- src/lib/paginator/paginator.scss | 1 - .../quantity-field-constants.ts | 4 +- src/lib/slider/slider.ts | 4 +- src/lib/split-button/split-button-adapter.ts | 14 +- .../split-button/split-button-constants.ts | 15 +- .../split-button/split-button-foundation.ts | 21 +- src/lib/split-button/split-button.scss | 29 +- src/lib/split-button/split-button.test.ts | 25 +- src/lib/split-button/split-button.ts | 31 +- src/lib/switch/switch.scss | 2 +- src/lib/switch/switch.ts | 2 +- src/lib/tabs/tab-bar/tab-bar-adapter.ts | 42 +- src/lib/tabs/tab-bar/tab-bar.scss | 18 +- src/lib/tabs/tabs.test.ts | 10 +- src/lib/time-picker/time-picker-adapter.ts | 14 +- src/lib/toast/toast.html | 6 +- src/lib/toast/toast.scss | 1 - .../autocomplete/code/autocomplete-default.ts | 4 +- .../button-area/code/button-area-default.ts | 16 +- .../src/components/card/code/card-styled.ts | 4 +- .../components/dialog/code/dialog-complex.ts | 6 +- .../icon-button/code/icon-button-default.ts | 6 +- .../icon-button/code/icon-button-toggle.ts | 8 +- .../code/quantity-field-default.ts | 12 +- src/test/spec/icon-button/icon-button.spec.ts | 376 ------ .../quantity-field.fixture.html | 4 +- .../quantity-field/quantity-field.spec.ts | 20 +- 116 files changed, 3491 insertions(+), 2548 deletions(-) create mode 100644 src/dev/pages/icon-button/icon-button.scss create mode 100644 src/lib/button/base/base-button.test.ts create mode 100644 src/lib/core/base/base-focusable-component.test.ts create mode 100644 src/lib/core/base/base-focusable-component.ts create mode 100644 src/lib/core/base/base-form-component.ts create mode 100644 src/lib/core/base/base-nullable-form-component.ts create mode 100644 src/lib/core/styles/tokens/icon-button/_tokens.scss create mode 100644 src/lib/icon-button/_configuration.scss create mode 100644 src/lib/icon-button/_core.scss delete mode 100644 src/lib/icon-button/_mixins.scss create mode 100644 src/lib/icon-button/_token-utils.scss delete mode 100644 src/lib/icon-button/_variables.scss delete mode 100644 src/lib/icon-button/forge-icon-button.scss create mode 100644 src/lib/icon-button/icon-button-adapter.ts create mode 100644 src/lib/icon-button/icon-button-foundation.ts create mode 100644 src/lib/icon-button/icon-button.html create mode 100644 src/lib/icon-button/icon-button.scss create mode 100644 src/lib/icon-button/icon-button.test.ts create mode 100644 src/lib/icon-button/index.scss delete mode 100644 src/test/spec/icon-button/icon-button.spec.ts diff --git a/package-lock.json b/package-lock.json index 54d05fc65..ee39851f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,6 @@ "@material/elevation": "^14.0.0", "@material/feature-targeting": "^14.0.0", "@material/form-field": "^14.0.0", - "@material/icon-button": "^14.0.0", "@material/menu-surface": "^14.0.0", "@material/ripple": "^14.0.0", "@material/rtl": "^14.0.0", @@ -2135,24 +2134,6 @@ "tslib": "^2.1.0" } }, - "node_modules/@material/icon-button": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-14.0.0.tgz", - "integrity": "sha512-wHMqzm7Q/UwbWLoWv32Li1r2iVYxadIrwTNxT0+p+7NdfI3lEwMN3NoB0CvoJnHTljjXDzce0KJ3nZloa0P0gA==", - "dependencies": { - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "tslib": "^2.1.0" - } - }, "node_modules/@material/line-ripple": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0.tgz", @@ -22753,24 +22734,6 @@ "tslib": "^2.1.0" } }, - "@material/icon-button": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-14.0.0.tgz", - "integrity": "sha512-wHMqzm7Q/UwbWLoWv32Li1r2iVYxadIrwTNxT0+p+7NdfI3lEwMN3NoB0CvoJnHTljjXDzce0KJ3nZloa0P0gA==", - "requires": { - "@material/base": "^14.0.0", - "@material/density": "^14.0.0", - "@material/dom": "^14.0.0", - "@material/elevation": "^14.0.0", - "@material/feature-targeting": "^14.0.0", - "@material/focus-ring": "^14.0.0", - "@material/ripple": "^14.0.0", - "@material/rtl": "^14.0.0", - "@material/theme": "^14.0.0", - "@material/touch-target": "^14.0.0", - "tslib": "^2.1.0" - } - }, "@material/line-ripple": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0.tgz", diff --git a/package.json b/package.json index 2a6717a0d..3ee33b48c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "@material/elevation": "^14.0.0", "@material/feature-targeting": "^14.0.0", "@material/form-field": "^14.0.0", - "@material/icon-button": "^14.0.0", "@material/menu-surface": "^14.0.0", "@material/ripple": "^14.0.0", "@material/rtl": "^14.0.0", diff --git a/src/dev/index.ejs b/src/dev/index.ejs index 582a2df64..032d24019 100644 --- a/src/dev/index.ejs +++ b/src/dev/index.ejs @@ -2,10 +2,8 @@ - - + + diff --git a/src/dev/pages/app-bar/app-bar.ejs b/src/dev/pages/app-bar/app-bar.ejs index e285f4354..435bf44bb 100644 --- a/src/dev/pages/app-bar/app-bar.ejs +++ b/src/dev/pages/app-bar/app-bar.ejs @@ -6,7 +6,7 @@ - + @@ -18,12 +18,12 @@ slot="end" avatar-text="First Last" full-name="First Last" - email="first.last@forge.tylerdev.io" + email="first.last@tylerforge.io" profile-button profile-button-text="View profile"> - + Tab 1 Tab 2 Tab 3 diff --git a/src/dev/pages/autocomplete/autocomplete.ejs b/src/dev/pages/autocomplete/autocomplete.ejs index b2c473917..c8adfef0c 100644 --- a/src/dev/pages/autocomplete/autocomplete.ejs +++ b/src/dev/pages/autocomplete/autocomplete.ejs @@ -4,10 +4,8 @@ - - + + diff --git a/src/dev/pages/autocomplete/autocomplete.ts b/src/dev/pages/autocomplete/autocomplete.ts index ae523bb21..85ce366f3 100644 --- a/src/dev/pages/autocomplete/autocomplete.ts +++ b/src/dev/pages/autocomplete/autocomplete.ts @@ -228,25 +228,17 @@ function headerBuilderCallback(): HTMLElement { const textField = document.createElement('forge-text-field'); textField.appendChild(input); - const button = document.createElement('button'); + const button = document.createElement('forge-icon-button'); button.style.marginLeft = '8px'; button.type = 'button'; - button.addEventListener('click', () => { - autocomplete.open = false; - }); + button.addEventListener('click', () => autocomplete.open = false); const icon = document.createElement('forge-icon'); icon.name = 'close'; button.appendChild(icon); - - const iconButton = document.createElement('forge-icon-button'); - iconButton.appendChild(button); - window.requestAnimationFrame(() => { - iconButton.layout(); - }); div.appendChild(textField); - div.appendChild(iconButton); + div.appendChild(button); return div; } diff --git a/src/dev/pages/badge/badge.ejs b/src/dev/pages/badge/badge.ejs index e9f4817e3..a7fe656be 100644 --- a/src/dev/pages/badge/badge.ejs +++ b/src/dev/pages/badge/badge.ejs @@ -8,23 +8,17 @@
- + - + 99 - + 999999+
diff --git a/src/dev/pages/button-area/button-area.ejs b/src/dev/pages/button-area/button-area.ejs index 2d1b76b60..82bebade8 100644 --- a/src/dev/pages/button-area/button-area.ejs +++ b/src/dev/pages/button-area/button-area.ejs @@ -7,11 +7,9 @@
Content
- - Like + + Like @@ -29,11 +27,9 @@
Subheading
- - Like + + Like diff --git a/src/dev/pages/button/button.ejs b/src/dev/pages/button/button.ejs index c77bb49d6..4d4fe0d45 100644 --- a/src/dev/pages/button/button.ejs +++ b/src/dev/pages/button/button.ejs @@ -1,32 +1,56 @@
- Default button - Raised button - Flat button - Outlined button - - w/<span> container - - - - - w/Leading icon - - - - w/Trailing icon - - - - - w/Anchor link - - - - Link button - - Custom button - -
+
+

Variants

+ Text button + Outlined button + Tonal button + Filled button + Raised button + Link button +
+ +
+

Slots

+ + + w/<span> text container + + + + + w/Start icon + + + + w/End icon + + +
+ +
+

Anchor

+ + w/Anchor link + + +
+ +
+

Custom

+ Custom button +
+ +
+

Label aware

+ + + + + Label text for the button + +
+ +

Form example

@@ -37,15 +61,15 @@ Reset
-
+ -
+

Popover target

Lorem, ipsum dolor sit amet consectetur adipisicing elit. Ipsam blanditiis cum quod, velit, architecto consequatur commodi sint odit non deleniti qui? Accusamus expedita, exercitationem laborum architecto facere quasi rem! Voluptate!
-
+ -
+

Dialog

Show dialog @@ -54,7 +78,7 @@ Close -
+ diff --git a/src/dev/pages/button/button.ts b/src/dev/pages/button/button.ts index 4e2f67887..f2d6a501e 100644 --- a/src/dev/pages/button/button.ts +++ b/src/dev/pages/button/button.ts @@ -6,6 +6,7 @@ import { IconRegistry } from '@tylertech/forge/icon'; import { tylIconFavorite, tylIconOpenInNew } from '@tylertech/tyler-icons/standard'; import { tylIconForgeLogo } from '@tylertech/tyler-icons/custom'; import '@tylertech/forge/button'; +import '@tylertech/forge/label'; import './button.scss'; IconRegistry.define([tylIconForgeLogo, tylIconFavorite, tylIconOpenInNew]); diff --git a/src/dev/pages/card/card.ejs b/src/dev/pages/card/card.ejs index 622722559..bc4b24f86 100644 --- a/src/dev/pages/card/card.ejs +++ b/src/dev/pages/card/card.ejs @@ -3,10 +3,8 @@

This is the card title

- - + + diff --git a/src/dev/pages/chips/chips.ejs b/src/dev/pages/chips/chips.ejs index 51d8e50ca..c7e1a83af 100644 --- a/src/dev/pages/chips/chips.ejs +++ b/src/dev/pages/chips/chips.ejs @@ -107,10 +107,8 @@

Input chips - - + +

diff --git a/src/dev/pages/dialog/dialog-template-basic.ejs b/src/dev/pages/dialog/dialog-template-basic.ejs index d596de848..09e55c7c6 100644 --- a/src/dev/pages/dialog/dialog-template-basic.ejs +++ b/src/dev/pages/dialog/dialog-template-basic.ejs @@ -2,10 +2,8 @@

Dialog title

- - + +
diff --git a/src/dev/pages/icon-button/icon-button.ejs b/src/dev/pages/icon-button/icon-button.ejs index ed2a9bf0a..fa8291c91 100644 --- a/src/dev/pages/icon-button/icon-button.ejs +++ b/src/dev/pages/icon-button/icon-button.ejs @@ -1,50 +1,98 @@
-
-

Default

- - - -
- -
-

Default (dense)

- - - -
+
+

Variants

+
+ + + + + + + + + + + -
+ + + + + + + +
+
+ +

Toggle

- - - -
- -
-

Toggle (dense)

- - - -
+
+ + + + -
+ + + + + + + + + + + + + + + + + + + +
+ + +

w/Anchor link

- - - - + + -
+ + +
+

Label aware

+
+ + + + + Settings + + + + + + Copy + +
+
+ +
+

Slots

+
+ + + + + + + + + +
+
diff --git a/src/dev/pages/icon-button/icon-button.html b/src/dev/pages/icon-button/icon-button.html index 51e177dfc..d7707e7b8 100644 --- a/src/dev/pages/icon-button/icon-button.html +++ b/src/dev/pages/icon-button/icon-button.html @@ -2,7 +2,49 @@ include('./src/partials/page.ejs', { page: { title: 'Icon button', - includePath: './pages/icon-button/icon-button.ejs' + includePath: './pages/icon-button/icon-button.ejs', + options: [ + { type: 'switch', label: 'Disabled', id: 'opt-disabled' }, + { type: 'switch', label: 'Dense', id: 'opt-dense' }, + { type: 'switch', label: 'Popover icon', id: 'opt-popover-icon' }, + { + type: 'select', + label: 'Density', + id: 'opt-density', + defaultValue: 'large', + options: [ + { label: 'Small (dense)', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large (default)', value: 'large' } + ] + }, + { + type: 'select', + label: 'Shape', + id: 'opt-shape', + defaultValue: 'circular', + options: [ + { label: 'Circular', value: 'circular' }, + { label: 'Squared', value: 'squared' } + ] + }, + { + type: 'select', + label: 'Theme', + id: 'opt-theme', + defaultValue: '', + options: [ + { label: 'Default', value: '' }, + { 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' } + ] + } + ] } }) %> diff --git a/src/dev/pages/icon-button/icon-button.scss b/src/dev/pages/icon-button/icon-button.scss new file mode 100644 index 000000000..c1ab24631 --- /dev/null +++ b/src/dev/pages/icon-button/icon-button.scss @@ -0,0 +1,5 @@ +.with-label { + display: inline-flex; + align-items: center; + flex-direction: column; +} diff --git a/src/dev/pages/icon-button/icon-button.ts b/src/dev/pages/icon-button/icon-button.ts index 97ea2057d..6318ead9f 100644 --- a/src/dev/pages/icon-button/icon-button.ts +++ b/src/dev/pages/icon-button/icon-button.ts @@ -1,13 +1,54 @@ import '$src/shared'; -import '@tylertech/forge/icon'; import '@tylertech/forge/icon-button'; -import '@tylertech/forge/icon-button/forge-icon-button.scss'; +import '@tylertech/forge/label'; +import { toggleAttribute } from '@tylertech/forge-core'; import { IconRegistry } from '@tylertech/forge/icon'; -import { tylIconCode, tylIconFavorite, tylIconFavoriteBorder, tylIconOpenInNew } from '@tylertech/tyler-icons/standard'; +import type { IIconButtonComponent } from '@tylertech/forge/icon-button'; +import type { ISelectComponent } from '@tylertech/forge/select'; +import type { ISwitchComponent } from '@tylertech/forge/switch'; +import { tylIconCheck, tylIconClose, tylIconFavorite, tylIconFileCopy, tylIconOpenInNew, tylIconSettings } from '@tylertech/tyler-icons/standard'; +import './icon-button.scss'; IconRegistry.define([ tylIconFavorite, - tylIconFavoriteBorder, - tylIconCode, - tylIconOpenInNew + tylIconCheck, + tylIconClose, + tylIconOpenInNew, + tylIconSettings, + tylIconFileCopy ]); + + +const allIconButtons = Array.from(document.querySelectorAll('.content forge-icon-button')); +allIconButtons.forEach(btn => btn.addEventListener('click', evt => console.log('click', evt))); + + +const disabledToggle = document.querySelector('#opt-disabled') as ISwitchComponent; +disabledToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + allIconButtons.forEach(btn => btn.disabled = selected); +}); + +const denseToggle = document.querySelector('#opt-dense') as ISwitchComponent; +denseToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + allIconButtons.forEach(btn => btn.dense = selected); +}); + +const popoverIconToggle = document.querySelector('#opt-popover-icon') as ISwitchComponent; +popoverIconToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + allIconButtons.forEach(btn => btn.popoverIcon = selected); +}); + +const densitySelect = document.querySelector('#opt-density') as ISelectComponent; +densitySelect.addEventListener('change', ({ detail }) => { + allIconButtons.forEach(btn => btn.density = detail); +}); + +const shapeSelect = document.querySelector('#opt-shape') as ISelectComponent; +shapeSelect.addEventListener('change', ({ detail }) => { + allIconButtons.forEach(btn => btn.shape = detail); +}); + +const themeSelect = document.querySelector('#opt-theme') as ISelectComponent; +themeSelect.addEventListener('change', ({ detail }) => { + allIconButtons.forEach(btn => toggleAttribute(btn, !!detail, 'theme', detail)); +}); diff --git a/src/dev/pages/quantity-field/quantity-field.ejs b/src/dev/pages/quantity-field/quantity-field.ejs index add300d8d..6cae9b5e2 100644 --- a/src/dev/pages/quantity-field/quantity-field.ejs +++ b/src/dev/pages/quantity-field/quantity-field.ejs @@ -1,17 +1,13 @@ - + - +
Choose your favorite number
diff --git a/src/dev/pages/split-button/split-button.ejs b/src/dev/pages/split-button/split-button.ejs index d7595e847..a568c05e1 100644 --- a/src/dev/pages/split-button/split-button.ejs +++ b/src/dev/pages/split-button/split-button.ejs @@ -1,14 +1,14 @@ -
+

Common

Send - + -
+ -
+

Multiple

Button one @@ -16,6 +16,6 @@ Button three Button four -
+ diff --git a/src/dev/pages/split-button/split-button.html b/src/dev/pages/split-button/split-button.html index 91219f16f..cf4d7f2d5 100644 --- a/src/dev/pages/split-button/split-button.html +++ b/src/dev/pages/split-button/split-button.html @@ -10,10 +10,26 @@ id: 'opt-variant', defaultValue: 'text', options: [ - { value: 'text', label: 'Text (default)' }, - { value: 'flat', label: 'Flat' }, - { value: 'raised', label: 'Raised' }, - { value: 'outlined', label: 'Outlined' } + { label: 'Text (default)', value: 'text' }, + { label: 'Outlined', value: 'outlined' }, + { label: 'Tonal', value: 'tonal'}, + { label: 'Filled', value: 'filled'}, + { label: 'Raised', value: 'raised' } + ] + }, + { + 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: 'Disabled', id: 'opt-disabled' }, diff --git a/src/dev/pages/split-button/split-button.ts b/src/dev/pages/split-button/split-button.ts index 673d3ddd4..4508610e2 100644 --- a/src/dev/pages/split-button/split-button.ts +++ b/src/dev/pages/split-button/split-button.ts @@ -1,6 +1,7 @@ import '$src/shared'; import '@tylertech/forge/split-button'; import { IconRegistry } from '@tylertech/forge/icon'; +import { ButtonTheme } from '@tylertech/forge/button'; import type { ISplitButtonComponent, SplitButtonVariant } from '@tylertech/forge/split-button'; import type { ISelectComponent } from '@tylertech/forge/select'; import type { ISwitchComponent } from '@tylertech/forge/switch'; @@ -22,6 +23,11 @@ variantSelect.addEventListener('change', ({ detail: variant }: CustomEvent splitButton.variant = variant); }); +const themeSelect = document.querySelector('#opt-theme') as ISelectComponent; +themeSelect.addEventListener('change', ({ detail: theme }: CustomEvent) => { + getSplitButtons().forEach(splitButton => splitButton.theme = theme); +}); + const disabledToggle = document.querySelector('#opt-disabled') as ISwitchComponent; disabledToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { getSplitButtons().forEach(splitButton => splitButton.disabled = selected); diff --git a/src/dev/pages/table/table.ts b/src/dev/pages/table/table.ts index 93d94be3f..2d00bda24 100644 --- a/src/dev/pages/table/table.ts +++ b/src/dev/pages/table/table.ts @@ -275,10 +275,8 @@ table.addEventListener('forge-table-filter', ({ detail }) => { }); function getMenuColumnTemplate(rowIndex: number): HTMLElement { - const forgeButton = document.createElement('forge-icon-button'); - const button = document.createElement('button'); + const button = document.createElement('forge-icon-button'); button.type = 'button'; - forgeButton.appendChild(button); const icon = document.createElement('forge-icon'); icon.name = 'more_vert'; @@ -297,17 +295,14 @@ function getMenuColumnTemplate(rowIndex: number): HTMLElement { table.data = data; } }); - menu.appendChild(forgeButton); + menu.appendChild(button); return menu; } function getExpandRowColumnTemplate(rowIndex: number): ITableTemplateBuilderResult { - const iconButton = document.createElement('forge-icon-button'); - - const button = document.createElement('button'); + const button = document.createElement('forge-icon-button'); button.type = 'button'; - iconButton.appendChild(button); const icon = document.createElement('forge-icon'); icon.name = 'chevron_right'; @@ -322,7 +317,7 @@ function getExpandRowColumnTemplate(rowIndex: number): ITableTemplateBuilderResu }); return { - content: iconButton, + content: button, stopClickPropagation: true }; } diff --git a/src/dev/pages/text-field/text-field.ejs b/src/dev/pages/text-field/text-field.ejs index 57965e02f..e074aec0f 100644 --- a/src/dev/pages/text-field/text-field.ejs +++ b/src/dev/pages/text-field/text-field.ejs @@ -10,10 +10,8 @@ - - + + diff --git a/src/dev/pages/theme/theme.ts b/src/dev/pages/theme/theme.ts index 291404e60..71fd68da3 100644 --- a/src/dev/pages/theme/theme.ts +++ b/src/dev/pages/theme/theme.ts @@ -34,44 +34,58 @@ const SWATCH_GROUPS: ISwatchGroup[] = [ header: 'Key colors', swatches: [ { text: 'Primary', background: 'primary', foreground: 'on-primary' }, - { text: 'Primary container', background: 'primary-container', foreground: 'on-primary-container' } + { text: 'Primary container (low)', background: 'primary-container-low', foreground: 'on-primary-container-low' }, + { text: 'Primary container', background: 'primary-container', foreground: 'on-primary-container' }, + { text: 'Primary container (high)', background: 'primary-container-high', foreground: 'on-primary-container-high' } ] }, { swatches: [ { text: 'Secondary', background: 'secondary', foreground: 'on-secondary' }, - { text: 'Secondary container', background: 'secondary-container', foreground: 'on-secondary-container' } + { text: 'Secondary container (low)', background: 'secondary-container-low', foreground: 'on-secondary-container-low'}, + { text: 'Secondary container', background: 'secondary-container', foreground: 'on-secondary-container' }, + { text: 'Secondary container (high)', background: 'secondary-container-high', foreground: 'on-secondary-container-high' } ] }, { swatches: [ { text: 'Tertiary', background: 'tertiary', foreground: 'on-tertiary' }, - { text: 'Tertiary container', background: 'tertiary-container', foreground: 'on-tertiary-container' } + { text: 'Tertiary container (low)', background: 'tertiary-container-low', foreground: 'on-tertiary-container-low' }, + { text: 'Tertiary container', background: 'tertiary-container', foreground: 'on-tertiary-container' }, + { text: 'Tertiary container (high)', background: 'tertiary-container-high', foreground: 'on-tertiary-container-high' } ] }, { header: 'Status', swatches: [ { text: 'Success', background: 'success', foreground: 'on-success' }, - { text: 'Success container', background: 'success-container', foreground: 'on-success-container' } + { text: 'Success container (low)', background: 'success-container-low', foreground: 'on-success-container-low' }, + { text: 'Success container', background: 'success-container', foreground: 'on-success-container' }, + { text: 'Success container (high)', background: 'success-container-high', foreground: 'on-success-container-high' } ] }, { swatches: [ { text: 'Error ', background: 'error', foreground: 'on-error' }, - { text: 'Error container', background: 'error-container', foreground: 'on-error-container' } + { text: 'Error container (low)', background: 'error-container-low', foreground: 'on-error-container-low' }, + { text: 'Error container', background: 'error-container', foreground: 'on-error-container' }, + { text: 'Error container (high)', background: 'error-container-high', foreground: 'on-error-container-high' } ] }, { swatches: [ { text: 'Warning', background: 'warning', foreground: 'on-warning' }, - { text: 'Warning container', background: 'warning-container', foreground: 'on-warning-container' } + { text: 'Warning container (low)', background: 'warning-container-low', foreground: 'on-warning-container-low' }, + { text: 'Warning container', background: 'warning-container', foreground: 'on-warning-container' }, + { text: 'Warning container (high)', background: 'warning-container-high', foreground: 'on-warning-container-high' } ] }, { swatches: [ { text: 'Info', background: 'info', foreground: 'on-info' }, - { text: 'Info container', background: 'info-container', foreground: 'on-info-container' } + { text: 'Info container (low)', background: 'info-container-low', foreground: 'on-info-container-low' }, + { text: 'Info container', background: 'info-container', foreground: 'on-info-container' }, + { text: 'Info container (high)', background: 'info-container-high', foreground: 'on-info-container-high' } ] }, { diff --git a/src/dev/src/partials/header.ejs b/src/dev/src/partials/header.ejs index 5d2d08475..a415e4da5 100644 --- a/src/dev/src/partials/header.ejs +++ b/src/dev/src/partials/header.ejs @@ -4,10 +4,8 @@ - - - Toggle theme + + + Toggle theme diff --git a/src/lib/app-bar/_mixins.scss b/src/lib/app-bar/_mixins.scss index 2425b9840..89e59c6b0 100644 --- a/src/lib/app-bar/_mixins.scss +++ b/src/lib/app-bar/_mixins.scss @@ -4,13 +4,13 @@ @use '@material/elevation/elevation-theme' as mdc-elevation-theme; @use '@material/theme/theme' as mdc-theme; @use '@material/theme/theme-color' as mdc-theme-color; -@use '@material/icon-button/mixins' as mdc-icon-button-mixins; @use '@material/top-app-bar/mixins' as mdc-top-app-bar-mixins; @use '@material/top-app-bar/variables' as mdc-top-app-bar-variables; @use '../theme'; @use './variables'; @use '../tabs/tab-bar'; @use '../tabs/tab'; +@use '../icon-button'; @mixin provide-theme($theme) { @include theme.theme-properties(app-bar, $theme, variables.$theme-values); @@ -237,8 +237,13 @@ )); } - ::slotted(forge-button[variant=outlined]), - ::slotted(forge-button:not([variant])) { + ::slotted(*) { + @include icon-button.provide-theme(( + icon-color: var(--forge-app-bar-theme-on-background, #{variables.$on-background}) + )); + } + + ::slotted(forge-button:is(:not([variant]),[variant=outlined])) { --forge-theme-primary: var(--forge-app-bar-theme-on-background, rgba(255, 255, 255, 0.87)); } } diff --git a/src/lib/app-bar/help-button/app-bar-help-button.html b/src/lib/app-bar/help-button/app-bar-help-button.html index 4695b27f4..2050c8681 100644 --- a/src/lib/app-bar/help-button/app-bar-help-button.html +++ b/src/lib/app-bar/help-button/app-bar-help-button.html @@ -1,10 +1,8 @@ \ No newline at end of file diff --git a/src/lib/app-bar/menu-button/app-bar-menu-button.html b/src/lib/app-bar/menu-button/app-bar-menu-button.html index ac8695e7f..16d00fb32 100644 --- a/src/lib/app-bar/menu-button/app-bar-menu-button.html +++ b/src/lib/app-bar/menu-button/app-bar-menu-button.html @@ -1,8 +1,6 @@ diff --git a/src/lib/app-bar/notification-button/app-bar-notification-button.html b/src/lib/app-bar/notification-button/app-bar-notification-button.html index 5913a9e93..e15f7950c 100644 --- a/src/lib/app-bar/notification-button/app-bar-notification-button.html +++ b/src/lib/app-bar/notification-button/app-bar-notification-button.html @@ -1,9 +1,7 @@ \ No newline at end of file diff --git a/src/lib/app-bar/profile-button/app-bar-profile-button-constants.ts b/src/lib/app-bar/profile-button/app-bar-profile-button-constants.ts index 6daf6366a..d304d5e80 100644 --- a/src/lib/app-bar/profile-button/app-bar-profile-button-constants.ts +++ b/src/lib/app-bar/profile-button/app-bar-profile-button-constants.ts @@ -17,7 +17,7 @@ const attributes = { }; const selectors = { - BUTTON: 'button' + BUTTON: 'forge-icon-button' }; export const APP_BAR_PROFILE_BUTTON_CONSTANTS = { diff --git a/src/lib/app-bar/profile-button/app-bar-profile-button.html b/src/lib/app-bar/profile-button/app-bar-profile-button.html index 0f22f3753..e8cf74e7c 100644 --- a/src/lib/app-bar/profile-button/app-bar-profile-button.html +++ b/src/lib/app-bar/profile-button/app-bar-profile-button.html @@ -1,8 +1,6 @@ \ No newline at end of file diff --git a/src/lib/banner/banner-constants.ts b/src/lib/banner/banner-constants.ts index 88eb2ed82..a8ae765f4 100644 --- a/src/lib/banner/banner-constants.ts +++ b/src/lib/banner/banner-constants.ts @@ -10,7 +10,7 @@ const classes = { const selectors = { BANNER: '.forge-banner', FORGE_DISMISS_BUTTON: 'forge-icon-button.forge-banner__container-dismiss', - DISMISS_BUTTON: '.forge-banner__container-dismiss button', + DISMISS_BUTTON: '.forge-banner__container-dismiss', ICON: 'i', FORGE_BUTTON: 'forge-button' }; diff --git a/src/lib/banner/banner.html b/src/lib/banner/banner.html index 5aca22ef5..09dcac333 100644 --- a/src/lib/banner/banner.html +++ b/src/lib/banner/banner.html @@ -14,12 +14,10 @@ - - - Dismiss + + + Dismiss \ No newline at end of file diff --git a/src/lib/banner/banner.scss b/src/lib/banner/banner.scss index bb08922d0..cbbdfc2ee 100644 --- a/src/lib/banner/banner.scss +++ b/src/lib/banner/banner.scss @@ -1,5 +1,4 @@ @use './mixins'; -@use '../icon-button/forge-icon-button'; @include mixins.core-styles; diff --git a/src/lib/bottom-sheet/bottom-sheet.scss b/src/lib/bottom-sheet/bottom-sheet.scss index 951bdffcd..a9b636d93 100644 --- a/src/lib/bottom-sheet/bottom-sheet.scss +++ b/src/lib/bottom-sheet/bottom-sheet.scss @@ -1,5 +1,4 @@ @use './mixins'; -@use '../icon-button/forge-icon-button'; @include mixins.core-styles; diff --git a/src/lib/button/_core.scss b/src/lib/button/_core.scss index 55a11e3b2..372a2a605 100644 --- a/src/lib/button/_core.scss +++ b/src/lib/button/_core.scss @@ -25,10 +25,10 @@ border-width: #{token(border-width)}; border-style: #{token(border-style)}; border-color: #{token(border-color)}; - border-top-left-radius: #{token(border-top-left-radius)}; - border-top-right-radius: #{token(border-top-right-radius)}; - border-bottom-left-radius: #{token(border-bottom-left-radius)}; - border-bottom-right-radius: #{token(border-bottom-right-radius)}; + border-start-start-radius: #{token(shape-start-start-radius)}; + border-start-end-radius: #{token(shape-start-end-radius)}; + border-end-start-radius: #{token(shape-end-start-radius)}; + border-end-end-radius: #{token(shape-end-end-radius)}; padding-block: #{token(padding-block)}; padding-inline: #{token(padding-inline)}; box-shadow: #{token(shadow)}; @@ -80,9 +80,12 @@ @include override(focus-indicator-offset, text-focus-indicator-offset); } +@mixin filled { + @include override(background, filled-background); + @include override(color, filled-color); +} + @mixin raised { - @include override(background, raised-background); - @include override(color, raised-color); @include override(shadow, raised-shadow); &:hover { @@ -94,9 +97,9 @@ } } -@mixin flat { - @include override(background, flat-background); - @include override(color, flat-color); +@mixin tonal { + @include override(background, tonal-background); + @include override(color, tonal-color); } @mixin outlined { @@ -139,15 +142,18 @@ pointer-events: none; } +@mixin filled-disabled { + @include override(background, filled-disabled-background); + @include override(color, filled-disabled-color); +} + @mixin raised-disabled { @include override(shadow, raised-disabled-shadow); - @include override(background, raised-disabled-background); - @include override(color, raised-disabled-color); } -@mixin flat-disabled { - @include override(background, flat-disabled-background); - @include override(color, flat-disabled-color); +@mixin tonal-disabled { + @include override(background, tonal-disabled-background); + @include override(color, tonal-disabled-color); } @mixin outlined-disabled { diff --git a/src/lib/button/base/base-button-adapter.ts b/src/lib/button/base/base-button-adapter.ts index 4b28e806c..1f9bdb7b9 100644 --- a/src/lib/button/base/base-button-adapter.ts +++ b/src/lib/button/base/base-button-adapter.ts @@ -6,7 +6,7 @@ import { IStateLayerComponent, STATE_LAYER_CONSTANTS } from '../../state-layer'; import { IBaseButton } from './base-button'; import { BASE_BUTTON_CONSTANTS } from './base-button-constants'; import { BUTTON_FORM_ATTRIBUTES, cloneAttributes } from '../../core/utils/reflect-utils'; -import { internals } from '../../constants'; +import { internals, isFocusable } from '../../constants'; import { supportsPopover } from '../../core/utils/feature-detection'; // TODO: remove this augmentation when the TypeScript version is upgraded for latest DOM typings @@ -27,7 +27,7 @@ export interface IBaseButtonAdapter extends IBaseAdapter { setAnchorDownload(value: string): void; setAnchorRel(value: string): void; setDisabled(value: boolean): void; - ensureAnchorEnabled(value: boolean): void; + syncDisabled(value: boolean): void; clickAnchor(): void; clickHost(): void; clickFormButton(type: string): void; @@ -36,6 +36,8 @@ export interface IBaseButtonAdapter extends IBaseAdapter { hasPopoverTarget(): boolean; managePopover(): boolean; toggleDefaultPopoverIcon(value: boolean): void; + animateStateLayer(): void; + proxyLabel(value: string | null): void; } export abstract class BaseButtonAdapter extends BaseAdapter implements IBaseButtonAdapter { @@ -45,6 +47,8 @@ export abstract class BaseButtonAdapter extends BaseAdapter impleme protected _stateLayerElement: IStateLayerComponent; protected _endSlotElement: HTMLSlotElement; + private _labelAwareText?: string; + constructor(component: IBaseButton) { super(component); this._rootElement = getShadowElement(this._component, BASE_BUTTON_CONSTANTS.selectors.ROOT) as HTMLButtonElement; @@ -97,10 +101,10 @@ export abstract class BaseButtonAdapter extends BaseAdapter impleme if (this._anchorElement) { return; // Cannot disable an anchor element } - this.ensureAnchorEnabled(value); + this.syncDisabled(value); } - public ensureAnchorEnabled(value: boolean): void { + public syncDisabled(value: boolean): void { if (value) { this._focusIndicatorElement.remove(); this._stateLayerElement.remove(); @@ -108,7 +112,7 @@ export abstract class BaseButtonAdapter extends BaseAdapter impleme this._rootElement.append(this._focusIndicatorElement, this._stateLayerElement); } - this._component.tabIndex = value ? -1 : 0; + this._component[isFocusable] = !value; toggleAttribute(this._component, value, 'aria-disabled', 'true'); } @@ -243,6 +247,19 @@ export abstract class BaseButtonAdapter extends BaseAdapter impleme } } + public animateStateLayer(): void { + if (this._stateLayerElement.disabled) { + return; + } + this._stateLayerElement?.playAnimation(); + } + + public proxyLabel(value: string | null): void { + this._labelAwareText = value ?? undefined; + const hasAriaLabel = this._component.hasAttribute('aria-label') || !!this._labelAwareText; + toggleAttribute(this._component, hasAriaLabel, 'aria-label', this._component.getAttribute('aria-label') ?? this._labelAwareText); + } + private _locatePopoverTargetElement(): TempHTMLElementWithPopover | null { let popoverElement = (this._component as TempHTMLElementWithPopover).popoverTargetElement ?? null; @@ -267,8 +284,8 @@ export abstract class BaseButtonAdapter extends BaseAdapter impleme // Set default role based on the existence of an anchor element this._component.role = this._anchorElement ? 'link' : 'button'; } - - this._component.tabIndex = !this._anchorElement && this._component.disabled ? -1 : 0; + + this._component[isFocusable] = this._anchorElement || !this._component.disabled; } /** diff --git a/src/lib/button/base/base-button-constants.ts b/src/lib/button/base/base-button-constants.ts index 02ec6dcb1..7a632cf66 100644 --- a/src/lib/button/base/base-button-constants.ts +++ b/src/lib/button/base/base-button-constants.ts @@ -7,7 +7,8 @@ const observedAttributes = { TARGET: 'target', DOWNLOAD: 'download', REL: 'rel', - DENSE: 'dense' + DENSE: 'dense', + TABINDEX: 'tabindex' }; const attributes = { diff --git a/src/lib/button/base/base-button-foundation.ts b/src/lib/button/base/base-button-foundation.ts index 31a91f531..1ad103768 100644 --- a/src/lib/button/base/base-button-foundation.ts +++ b/src/lib/button/base/base-button-foundation.ts @@ -12,7 +12,8 @@ export interface IBaseButtonFoundation extends ICustomElementFoundation { download: string; rel: string; dense: boolean; - click(): void; + click(options: { animateStateLayer: boolean }): void; + proxyLabel(label: string | null): void; } export abstract class BaseButtonFoundation implements IBaseButtonFoundation { @@ -54,15 +55,27 @@ export abstract class BaseButtonFoundation impleme /** * Handles overriding the the `click()` method on the HTMLElement instance */ - public click(): void { + public click({ animateStateLayer = false } = {}): void { + if (this._disabled) { + return; + } + if (this._anchor) { this._adapter.clickAnchor(); } else { this._adapter.clickHost(); } + + if (animateStateLayer) { + this._adapter.animateStateLayer(); + } + } + + public proxyLabel(label: string | null): void { + this._adapter.proxyLabel(label); } - private async _onClick(evt: MouseEvent): Promise { + protected async _onClick(evt: MouseEvent): Promise { const isFormType = this._type === 'submit' || this._type === 'reset'; // Custom elements do not work with the popover* attributes by default so we need to manually @@ -128,6 +141,7 @@ export abstract class BaseButtonFoundation impleme this.disabled = false; // Anchor elements are always enabled } else { this._adapter.removeAnchor(); + this._manageAnchorListeners(); } } @@ -158,7 +172,7 @@ export abstract class BaseButtonFoundation impleme // If we're in anchor mode, we need to ensure that the anchor is always enabled if (this._anchor) { if (this._disabled) { - this._adapter.ensureAnchorEnabled(false); + this._adapter.syncDisabled(false); } value = false; } diff --git a/src/lib/button/base/base-button.test.ts b/src/lib/button/base/base-button.test.ts new file mode 100644 index 000000000..64da3957a --- /dev/null +++ b/src/lib/button/base/base-button.test.ts @@ -0,0 +1,1151 @@ +import { expect } from '@esm-bundle/chai'; +import { spy } from 'sinon'; +import { elementUpdated, fixture, html } from '@open-wc/testing'; +import { sendKeys, sendMouse } from '@web/test-runner-commands'; +import { tylIconArrowDropDown } from '@tylertech/tyler-icons/standard'; +import { BASE_BUTTON_CONSTANTS } from '../base/base-button-constants'; +import type { IButtonComponent } from '../button'; +import type { IStateLayerComponent } from '../../state-layer'; +import type { IFocusIndicatorComponent } from '../../focus-indicator'; +import type { IIconComponent } from '../../icon'; +import type { ILabelComponent } from '../../label/label'; +import { attachShadowTemplate } from '@tylertech/forge-core'; +import { BaseButton } from './base-button'; +import { BaseButtonFoundation } from './base-button-foundation'; +import { BaseButtonAdapter, IBaseButtonAdapter } from './base-button-adapter'; + +import '../../focus-indicator/focus-indicator'; +import '../../state-layer/state-layer'; +import '../../label/label'; + +class TestBaseButtonFoundation extends BaseButtonFoundation {} +class TestBaseButtonAdapter extends BaseButtonAdapter implements IBaseButtonAdapter {} + +const template = ` + +`; + +const styles = ` + :host { + position: relative; + display: inline-flex; + } + + a { + position: absolute; + inset: 0; + } +`; + +class TestBaseButton extends BaseButton { + public static get observedAttributes(): string[] { + return [...Object.values(BASE_BUTTON_CONSTANTS.observedAttributes) as string[]]; + } + protected readonly _foundation: TestBaseButtonFoundation; + constructor() { + super(); + attachShadowTemplate(this, template, styles); + this._foundation = new TestBaseButtonFoundation(new TestBaseButtonAdapter(this)); + } +} + +window.customElements.define('forge-test-base-button', TestBaseButton); + +describe('BaseButton', () => { + it('should allow for alternate role', async () => { + const el = await fixture(html`Button`); + await elementUpdated(el); + + expect(el.role).to.equal('presentation'); + }); + + it('should allow for alternate role dynamically', async () => { + const el = await fixture(html`Button`); + + el.role = 'presentation'; + expect(el.role).to.equal('presentation'); + + await elementUpdated(el); + + el.href = 'javascript: void(0);'; + expect(el.role).to.equal('presentation'); + + await elementUpdated(el); + + el.href = ''; + expect(el.role).to.equal('presentation'); + }); + + it('should show focus indicator when focused', async () => { + const el = await fixture(html`Button`); + + const focusIndicator = getFocusIndicator(el); + expect(focusIndicator.active).to.be.false; + + el.focus(); + + expect(el.matches(':focus-visible')).to.be.true; + expect(focusIndicator.active).to.be.true; + }); + + it('should not set popover icon by default', async () => { + const el = await fixture(html`Button`); + + const popoverIcon = getPopoverIcon(el); + + expect(el.popoverIcon).to.be.false; + expect(el.hasAttribute('popover-icon')).to.be.false; + expect(popoverIcon).not.to.be.ok; + }); + + it('should set default popover icon', async () => { + const el = await fixture(html`Button`); + + const popoverIcon = getPopoverIcon(el); + expect(el.popoverIcon).to.be.true; + expect(el.hasAttribute('popover-icon')).to.be.true; + expect(popoverIcon).to.be.ok; + expect(popoverIcon.name).to.equal(tylIconArrowDropDown.name); + await expect(el).to.be.accessible(); + }); + + it('should set popover icon dynamically', async () => { + const el = await fixture(html`Button`); + + await elementUpdated(el); + el.popoverIcon = true; + + const popoverIcon = getPopoverIcon(el); + expect(el.popoverIcon).to.be.true; + expect(el.hasAttribute('popover-icon')).to.be.true; + expect(popoverIcon).to.be.ok; + expect(popoverIcon.name).to.equal(tylIconArrowDropDown.name); + }); + + it('should remove popover icon dynamically', async () => { + const el = await fixture(html`Button`); + + let popoverIcon = getPopoverIcon(el); + expect(popoverIcon).to.be.ok; + + el.popoverIcon = false; + + popoverIcon = getPopoverIcon(el); + expect(el.popoverIcon).to.be.false; + expect(el.hasAttribute('popover-icon')).to.be.false; + expect(popoverIcon).not.to.be.ok; + }); + + it('should set dense', async () => { + const el = await fixture(html`Button`); + + expect(el.dense).to.be.true; + expect(el.hasAttribute('dense')).to.be.true; + await expect(el).to.be.accessible(); + }); + + it('should set type to button by default', async () => { + const el = await fixture(html`Button`); + + expect(el.type).to.equal('button'); + await expect(el).to.be.accessible(); + }); + + it('should set type to submit', async () => { + const el = await fixture(html`Button`); + + expect(el.type).to.equal('submit'); + expect(el.getAttribute('type')).to.equal('submit'); + await expect(el).to.be.accessible(); + }); + + it('should set type to reset', async () => { + const el = await fixture(html`Button`); + + expect(el.type).to.equal('reset'); + expect(el.getAttribute('type')).to.equal('reset'); + await expect(el).to.be.accessible(); + }); + + it('should be disabled', async () => { + const el = await fixture(html`Button`); + + expect(el.disabled).to.be.true; + expect(el.hasAttribute('disabled')).to.be.true; + expect(el.getAttribute('aria-disabled')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should set disabled dynamically', async () => { + const el = await fixture(html`Button`); + + el.disabled = true; + + let stateLayer = getStateLayer(el); + let focusIndicator = getFocusIndicator(el); + + expect(el.disabled).to.be.true; + expect(el.hasAttribute('disabled')).to.be.true; + expect(el.getAttribute('aria-disabled')).to.equal('true'); + expect(stateLayer).not.to.be.ok; + expect(focusIndicator).not.to.be.ok; + await expect(el).to.be.accessible(); + + el.disabled = false; + + stateLayer = getStateLayer(el); + focusIndicator = getFocusIndicator(el); + + expect(el.disabled).to.be.false; + expect(el.hasAttribute('disabled')).to.be.false; + expect(el.hasAttribute('aria-disabled')).to.be.false; + expect(el.getAttribute('tabindex')).to.equal('0'); + expect(stateLayer).to.be.ok; + expect(focusIndicator).to.be.ok; + }); + + it('should not disable when href is specified', async () => { + const el = await fixture(html`Button`); + + const stateLayer = getStateLayer(el); + const focusIndicator = getFocusIndicator(el); + + expect(el.disabled).to.be.false; + expect(el.hasAttribute('disabled')).to.be.false; + expect(el.hasAttribute('aria-disabled')).to.be.false; + expect(el.getAttribute('tabindex')).to.equal('0'); + expect(stateLayer).to.be.ok; + expect(focusIndicator).to.be.ok; + await expect(el).to.be.accessible(); + }); + + it('should not disable dynamically when href is specified', async () => { + const el = await fixture(html`Button`); + + el.disabled = true; + await elementUpdated(el); + + const stateLayer = getStateLayer(el); + const focusIndicator = getFocusIndicator(el); + + expect(el.disabled).to.be.false; + expect(el.hasAttribute('disabled')).to.be.false; + expect(el.hasAttribute('aria-disabled')).to.be.false; + expect(el.getAttribute('tabindex')).to.equal('0'); + expect(stateLayer).to.be.ok; + expect(focusIndicator).to.be.ok; + await expect(el).to.be.accessible(); + }); + + it('should focus element when focus() is called', async () => { + const el = await fixture(html`Button`); + + el.focus(); + + expect(document.activeElement).to.equal(el); + }); + + it('should focus element when clicked', async () => { + const el = await fixture(html`Button`); + + await clickElement(el); + + expect(document.activeElement).to.equal(el); + }); + + it('should not focus element if clicked when disabled', async () => { + const el = await fixture(html`Button`); + + await clickElement(el); + + expect(document.activeElement).not.to.equal(el); + }); + + it('should dispatch click event when click() is called', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.click(); + + expect(clickSpy.calledOnce).to.be.true; + }); + + it('should dispatch click event when clicked', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + await clickElement(el); + + expect(clickSpy.calledOnce).to.be.true; + }); + + it('should dispatch click event when enter key is pressed', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.focus(); + await pressKey('Enter'); + await elementUpdated(el); + + expect(clickSpy.calledOnce).to.be.true; + }); + + it('should dispatch click event when space key is pressed', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.focus(); + await pressKey(' '); + await elementUpdated(el); + + expect(clickSpy.calledOnce).to.be.true; + }); + + it('should not dispatch click event is click event is canceled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', evt => evt.preventDefault()); + + el.focus(); + await pressKey('Enter'); + await pressKey(' '); + await elementUpdated(el); + + expect(clickSpy).not.to.be.have.been.called; + }); + + it('should not dispatch click event is keydown event is canceled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('keydown', evt => evt.preventDefault()); + + el.focus(); + await pressKey('Enter'); + await pressKey(' '); + await elementUpdated(el); + + expect(clickSpy).not.to.be.have.been.called; + }); + + it('should not dispatch click event if click() is called when disabled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.click(); + + expect(clickSpy.calledOnce).to.be.false; + }); + + it('should not dispatch click event if clicked when disabled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + await clickElement(el); + + expect(clickSpy.calledOnce).to.be.false; + }); + + it('should not dispatch click event if enter key is pressed when disabled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.focus(); + await pressKey('Enter'); + await elementUpdated(el); + + expect(clickSpy.calledOnce).to.be.false; + }); + + it('should not dispatch click event if space key is pressed when disabled', async () => { + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.focus(); + await pressKey(' '); + await elementUpdated(el); + + expect(clickSpy.calledOnce).to.be.false; + }); + + it('should render tag when anchor is set', async () => { + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + expect(el.anchor).to.be.true; + expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; + expect(el.hasAttribute('anchor')).to.be.true; + expect(anchorEl.tabIndex).to.be.equal(-1); + expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should render tag when anchor is set dynamically', async () => { + const el = await fixture(html`Button`); + + el.anchor = true; + + const anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + expect(el.anchor).to.be.true; + expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; + expect(anchorEl.tabIndex).to.be.equal(-1); + expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should tag when anchor is set dynamically', async () => { + const el = await fixture(html`Button`); + + expect(el.anchor).to.be.true; + + el.anchor = false; + + const anchorEl = getAnchorEl(el); + expect(anchorEl).not.to.be.ok; + expect(el.anchor).to.be.false; + expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.false; + await expect(el).to.be.accessible(); + }); + + it('should render tag when href is set', async () => { + const href = `javascript: console.log('href button')`; + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; + expect(el.anchor).to.be.true; + expect(el.href).to.equal(href); + expect(anchorEl.href).to.equal(href); + expect(anchorEl.tabIndex).to.be.equal(-1); + expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should render tag when href is set dynamically', async () => { + const el = await fixture(html`Button`); + + const href = `javascript: console.log('href button')`; + el.href = href; + + const anchorEl = getAnchorEl(el); + expect(el.anchor).to.be.true; + expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; + expect(el.href).to.equal(href); + expect(anchorEl.href).to.equal(href); + expect(anchorEl.tabIndex).to.be.equal(-1); + expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should set all state on when href is set after', async () => { + const el = await fixture(html`Button`); + + let anchorEl = getAnchorEl(el); + expect(anchorEl).not.to.be.ok; + + el.target = '_blank'; + el.download = 'test'; + el.rel = 'noopener'; + el.href = 'javascript: void(0);'; // Set this last to ensure other anchor state is set first + + anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + expect(anchorEl.getAttribute('target')).to.equal(el.target); + expect(anchorEl.getAttribute('download')).to.equal(el.download); + expect(anchorEl.getAttribute('rel')).to.equal(el.rel); + }); + + it('should render tag when anchor is set and href is set', async () => { + const href = `javascript: console.log('href button')`; + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + expect(el.anchor).to.be.true; + expect(el.hasAttribute('anchor')).to.be.true; + expect(el.href).to.equal(href); + expect(anchorEl.href).to.equal(href); + expect(anchorEl.tabIndex).to.be.equal(-1); + expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); + await expect(el).to.be.accessible(); + }); + + it('should remove tag when anchor is set to false', async () => { + const el = await fixture(html`Button`); + + el.anchor = false; + + const anchorEl = getAnchorEl(el); + expect(anchorEl).not.to.be.ok; + expect(el.anchor).to.be.false; + expect(el.hasAttribute('anchor')).to.be.false; + }); + + it('should set anchor state when href is removed', async () => { + const el = await fixture(html`Button`); + + el.removeAttribute('href'); + + const anchorEl = getAnchorEl(el); + expect(anchorEl).not.to.be.ok; + expect(el.anchor).to.be.false; + expect(el.hasAttribute('anchor')).to.be.false; + expect(el.hasAttribute('href')).to.be.false; + }); + + it('should click tag when click() is called', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.click(); + await elementUpdated(el); + delete window['forgeAnchorTest']; + + expect(clickSpy).to.have.been.calledOnce; + }); + + it('should click tag via mouse', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const testSpy = spy(window as any, 'forgeAnchorTest'); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + await clickElement(el); + delete window['forgeAnchorTest']; + + expect(clickSpy).to.have.been.calledOnce; + expect(testSpy).to.have.been.calledOnce; + }); + + it('should not click tag when click event is canceled', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const testSpy = spy(window as any, 'forgeAnchorTest'); + el.addEventListener('click', evt => evt.preventDefault()); + + el.click(); + await elementUpdated(el); + delete window['forgeAnchorTest']; + + expect(testSpy).not.to.have.been.called; + }); + + it('should click tag via keyboard', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const testSpy = spy(window as any, 'forgeAnchorTest'); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.focus(); + await pressKey('Enter'); + await elementUpdated(el); + delete window['forgeAnchorTest']; + + expect(clickSpy).to.have.been.calledOnce; + expect(testSpy).to.have.been.calledOnce; + }); + + it('should not click tag when enter key is pressed and event is canceled', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const testSpy = spy(window as any, 'forgeAnchorTest'); + el.addEventListener('click', evt => evt.preventDefault()); + + el.focus(); + await pressKey('Enter'); + await elementUpdated(el); + delete window['forgeAnchorTest']; + + expect(testSpy).not.to.have.been.called; + }); + + it('should not disable ', async () => { + window['forgeAnchorTest'] = () => {}; + const href = `javascript: forgeAnchorTest()`; + const el = await fixture(html`Button`); + const clickSpy = spy(); + el.addEventListener('click', clickSpy); + + el.click(); + await elementUpdated(el); + delete window['forgeAnchorTest']; + + expect(clickSpy).to.have.been.called; + }); + + it('should enable button when anchor is set while disabled', async () => { + const el = await fixture(html`Button`); + + el.anchor = true; + await elementUpdated(el); + + expect(el.disabled).to.be.false; + }); + + it('should enable button when href is set while disabled', async () => { + const el = await fixture(html`Button`); + + el.href = 'javascript: void(0);'; + await elementUpdated(el); + + expect(el.disabled).to.be.false; + }); + + it('should set target', async () => { + const target = '_blank'; + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(el.target).to.equal(target); + expect(el.getAttribute('target')).to.equal(target); + expect(anchorEl.target).to.equal(target); + await expect(el).to.be.accessible(); + }); + + it('should set download', async () => { + const download = 'test'; + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(el.download).to.equal(download); + expect(el.getAttribute('download')).to.equal(download); + expect(anchorEl.download).to.equal(download); + await expect(el).to.be.accessible(); + }); + + it('should set rel', async () => { + const rel = 'test'; + const el = await fixture(html`Button`); + + const anchorEl = getAnchorEl(el); + expect(el.rel).to.equal(rel); + expect(el.getAttribute('rel')).to.equal(rel); + expect(anchorEl.rel).to.equal(rel); + await expect(el).to.be.accessible(); + }); + + it('should switch from to default', async () => { + const el = await fixture(html`Button`); + + let anchorEl = getAnchorEl(el); + expect(anchorEl).to.be.ok; + + el.href = ''; + anchorEl = getAnchorEl(el); + + expect(anchorEl).not.to.be.ok; + }); + + it('should show popover when click() method is called', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(toggleSpy).to.have.been.calledOnce; + }); + + it('should show popover when clicked', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + await clickElement(buttonEl); + toggleSpy.restore(); + + expect(toggleSpy).to.have.been.calledOnce; + }); + + it('should show popover when enter key is pressed', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + buttonEl.focus(); + await pressKey('Enter'); + await elementUpdated(el); + + expect(toggleSpy).to.have.been.calledOnce; + }); + + it('should not show popover when clicked if child of
', async () => { + const el = await fixture(html` + + Button +
Popover
+ + `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + await clickElement(buttonEl); + + expect(toggleSpy).not.to.have.been.called; + }); + + it('should not show popover if cannot locate target element', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const toggleSpy = spy(buttonEl as any, 'togglePopover'); + + await clickElement(buttonEl); + + expect(toggleSpy).not.to.have.been.called; + }); + + it('should hide popover when clicked', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + + await clickElement(buttonEl); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.true; + + await clickElement(buttonEl); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.false; + }); + + it('should hide popover when enter key is pressed', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + + buttonEl.focus(); + await pressKey('Enter'); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.true; + + buttonEl.focus(); + await pressKey('Enter'); + await elementUpdated(el); + expect(popoverEl.matches(':popover-open')).to.be.false; + }); + + it('should not show popover if popovertargetaction is set to hide', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const showSpy = spy(popoverEl as any, 'showPopover'); + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + await clickElement(buttonEl); + + expect(showSpy).not.to.have.been.called; + expect(toggleSpy).not.to.have.been.called; + }); + + it('should show popover if popovertargetaction is set to show', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + const showSpy = spy(popoverEl as any, 'showPopover'); + const toggleSpy = spy(popoverEl as any, 'togglePopover'); + + await clickElement(buttonEl); + + expect(showSpy).to.have.been.calledOnce; + expect(toggleSpy).not.to.have.been.called; + }); + + it('should not hide popover if popovertargetaction is set to show', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + + await clickElement(buttonEl); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.true; + + await clickElement(buttonEl); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.true; + }); + + it('should hide popover if popovertargetaction is set to hide', async () => { + const el = await fixture(html` +
+ Button +
Popover
+
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const popoverEl = el.querySelector('[popover]') as HTMLElement; + + await clickElement(buttonEl); + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.false; + + popoverEl['showPopover'](); // TODO: need updated typings + await elementUpdated(popoverEl); + expect(popoverEl.matches(':popover-open')).to.be.true; + + await clickElement(buttonEl); + + expect(popoverEl.matches(':popover-open')).to.be.false; + }); + + it('should set form reference', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + expect(buttonEl.form).to.equal(el); + }); + + it('should submit form when click() is called', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(); + el.addEventListener('submit', submitSpy); + await elementUpdated(buttonEl); + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should submit form when clicked by mouse', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(); + el.addEventListener('submit', submitSpy); + + await clickElement(buttonEl); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should submit form when enter key is pressed', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(); + el.addEventListener('submit', submitSpy); + + buttonEl.focus(); + await pressKey('Enter'); + await elementUpdated(el); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should submit form when space key is pressed', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(); + el.addEventListener('submit', submitSpy); + + buttonEl.focus(); + await pressKey(' '); + await elementUpdated(el); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should reset form when click() is called', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const resetSpy = spy(); + el.addEventListener('reset', resetSpy); + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(resetSpy).to.have.been.calledOnce; + }); + + it('should not submit form when click event is canceled', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(evt => evt.preventDefault()); + el.addEventListener('submit', submitSpy); + + const clickSpy = spy(evt => evt.preventDefault()); + buttonEl.addEventListener('click', clickSpy); + + await clickElement(buttonEl); + await elementUpdated(buttonEl); + + expect(clickSpy).to.have.been.called; + expect(submitSpy).not.to.have.been.called; + }); + + it('should set correct form submit event submitter', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(evt => { + expect(evt.submitter).to.equal(buttonEl); + }); + el.addEventListener('submit', submitSpy); + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should set name and value', async () => { + const el = await fixture(html`Button`); + + expect(el.name).to.equal('test'); + expect(el.getAttribute('name')).to.equal('test'); + expect(el.value).to.equal('test-value'); + expect(el.getAttribute('value')).to.equal('test-value'); + + el.name = 'updated-name'; + el.value = 'updated-value' + + expect(el.name).to.equal('updated-name'); + expect(el.getAttribute('name')).to.equal('updated-name'); + expect(el.value).to.equal('updated-value'); + expect(el.getAttribute('value')).to.equal('updated-value'); + }); + + it('should submit form with name', async () => { + const el = await fixture(html` +
+ Button +
+ `); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + const submitSpy = spy(evt => { + const { name } = evt.submitter as HTMLButtonElement; + const formData = new FormData(el); + + expect(name).to.equal('test'); + expect(buttonEl.value).to.equal('test-value'); + expect(formData.get('test')).to.equal(buttonEl.value); + }); + el.addEventListener('submit', submitSpy); + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(submitSpy).to.have.been.calledOnce; + }); + + it('should close native clicked as submit type', async () => { + const el = await fixture(html` + +
+ Button +
+
+ `); + + el.showModal(); + + const buttonEl = el.querySelector('forge-test-base-button') as IButtonComponent; + + expect(el.open).to.be.true; + + buttonEl.click(); + await elementUpdated(buttonEl); + + expect(el.open).to.be.false; + }); + + it('should be label aware', async () => { + const el = await fixture(html` +
+ Button + Test label +
+ `); + + const button = el.querySelector('forge-test-base-button') as IButtonComponent; + + expect(button.getAttribute('aria-label')).to.equal('Test label'); + await expect(el).to.be.accessible(); + }); + + it('should preserve aria-label when label aware', async () => { + const el = await fixture(html` +
+ Button + Test label +
+ `); + + const button = el.querySelector('forge-test-base-button') as IButtonComponent; + + expect(button.getAttribute('aria-label')).to.equal('Button'); + await expect(el).to.be.accessible(); + }); + + it('should dispatch click event when clicking label element', async () => { + const el = await fixture(html` +
+ Button + Test label +
+ `); + + const button = el.querySelector('forge-test-base-button') as IButtonComponent; + const label = el.querySelector('forge-label') as ILabelComponent; + const clickSpy = spy(); + + button.addEventListener('click', clickSpy); + label.click(); + + expect(clickSpy).to.be.calledOnce; + }); + + function getAnchorEl(el: IButtonComponent): HTMLAnchorElement { + return el.shadowRoot?.querySelector('a') as HTMLAnchorElement; + } + + function getStateLayer(btn: IButtonComponent): IStateLayerComponent { + return btn.shadowRoot?.querySelector('forge-state-layer') as IStateLayerComponent + } + + function getFocusIndicator(btn: IButtonComponent): IFocusIndicatorComponent { + return btn.shadowRoot?.querySelector('forge-focus-indicator') as IFocusIndicatorComponent; + } + + function getPopoverIcon(btn: IButtonComponent): IIconComponent { + return btn.shadowRoot?.querySelector('slot[name=end] > forge-icon') as IIconComponent; + } + + 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), + ]}); + } + + function pressKey(press: ' ' | 'Enter'): Promise { + return sendKeys({ press }); + } +}); diff --git a/src/lib/button/base/base-button.ts b/src/lib/button/base/base-button.ts index af017e6ff..88792de63 100644 --- a/src/lib/button/base/base-button.ts +++ b/src/lib/button/base/base-button.ts @@ -1,9 +1,11 @@ import { coerceBoolean, FoundationProperty } from '@tylertech/forge-core'; +import { BaseFocusableComponent } from '../../core/base/base-focusable-component'; import { internals } from '../../constants'; -import { BaseComponent, IBaseComponent } from '../../core/base/base-component'; +import { IBaseComponent } from '../../core/base/base-component'; import { IBaseButtonAdapter } from './base-button-adapter'; import { BASE_BUTTON_CONSTANTS, ButtonType } from './base-button-constants'; import { BaseButtonFoundation } from './base-button-foundation'; +import { ILabelAware } from '../../label/label-aware'; export interface IBaseButton extends IBaseComponent { type: ButtonType; @@ -20,7 +22,7 @@ export interface IBaseButton extends IBaseComponent { form: HTMLFormElement | null; } -export abstract class BaseButton> extends BaseComponent implements IBaseButton { +export abstract class BaseButton> extends BaseFocusableComponent implements IBaseButton, ILabelAware { public static readonly formAssociated = true; public [internals]: ElementInternals; @@ -32,11 +34,12 @@ export abstract class BaseButton
- - - + + +
diff --git a/src/lib/button/button.scss b/src/lib/button/button.scss index 40a4868ba..661a93640 100644 --- a/src/lib/button/button.scss +++ b/src/lib/button/button.scss @@ -59,7 +59,14 @@ forge-focus-indicator { @mixin theme($theme) { :host([theme=#{$theme}]) { .forge-button { - @include override(primary-color, theme.variable($theme)); + @include override(primary-color, theme.variable($theme), value); + } + } + + :host([theme=#{$theme}][variant=tonal]) { + .forge-button { + @include override(tonal-background, theme.variable(#{$theme}-container), value); + @include override(tonal-color, theme.variable(on-#{$theme}-container), value); } } } @@ -81,23 +88,29 @@ forge-focus-indicator { } } -:host([variant=raised]) { +:host(:is([variant=filled],[variant=raised])) { .forge-button { - @include core.raised; + @include core.filled; } forge-state-layer { - @include state-layer.provide-theme(( color: #{token(raised-color)} )); + @include state-layer.provide-theme(( color: #{token(filled-color)} )); + } +} + +:host([variant=raised]) { + .forge-button { + @include core.raised; } } -:host([variant=flat]) { +:host([variant=tonal]) { .forge-button { - @include core.flat; + @include core.tonal; } forge-state-layer { - @include state-layer.provide-theme(( color: #{token(flat-color)} )); + @include state-layer.provide-theme(( color: #{token(tonal-color)} )); } } @@ -149,21 +162,27 @@ forge-focus-indicator { } } -:host(:not([anchor])[variant=raised][disabled]) { +:host(:not([anchor])[variant=outlined][disabled]) { .forge-button { - @include core.raised-disabled; + @include core.outlined-disabled; } } -:host(:not([anchor])[variant=flat][disabled]) { +:host(:not([anchor])[variant=tonal][disabled]) { .forge-button { - @include core.flat-disabled; + @include core.tonal-disabled; } } -:host(:not([anchor])[variant=outlined][disabled]) { +:host(:not([anchor]):is([variant=filled],[variant=raised])[disabled]) { .forge-button { - @include core.outlined-disabled; + @include core.filled-disabled; + } +} + +:host(:not([anchor])[variant=raised][disabled]) { + .forge-button { + @include core.raised-disabled; } } diff --git a/src/lib/button/button.test.ts b/src/lib/button/button.test.ts index 35bc5406d..eb97464aa 100644 --- a/src/lib/button/button.test.ts +++ b/src/lib/button/button.test.ts @@ -1,13 +1,12 @@ import { expect } from '@esm-bundle/chai'; import { spy } from 'sinon'; -import { elementUpdated, fixture, html } from '@open-wc/testing'; -import { sendKeys, sendMouse } from '@web/test-runner-commands'; -import { tylIconArrowDropDown } from '@tylertech/tyler-icons/standard'; +import { fixture, html } from '@open-wc/testing'; +import { sendMouse } from '@web/test-runner-commands'; import { BASE_BUTTON_CONSTANTS } from './base/base-button-constants'; -import type { IButtonComponent } from './button'; +import { ButtonComponent, IButtonComponent } from './button'; import type { IStateLayerComponent } from '../state-layer'; import type { IFocusIndicatorComponent } from '../focus-indicator'; -import type { IIconComponent } from '../icon'; +import { ButtonComponentDelegate } from './button-component-delegate'; import './button'; @@ -33,30 +32,6 @@ describe('Button', () => { await expect(el).to.be.accessible(); }); - it('should allow for alternate role', async () => { - const el = await fixture(html`Button`); - await elementUpdated(el); - - expect(el.role).to.equal('presentation'); - }); - - it('should allow for alternate role dynamically', async () => { - const el = await fixture(html`Button`); - - el.role = 'presentation'; - expect(el.role).to.equal('presentation'); - - await elementUpdated(el); - - el.href = 'javascript: void(0);'; - expect(el.role).to.equal('presentation'); - - await elementUpdated(el); - - el.href = ''; - expect(el.role).to.equal('presentation'); - }); - it('should be text variant by default', async () => { const el = await fixture(html`Button`); @@ -105,18 +80,6 @@ describe('Button', () => { expect(stateLayer.disabled).to.be.false; }); - it('should show focus indicator when focused', async () => { - const el = await fixture(html`Button`); - - const focusIndicator = getFocusIndicator(el); - expect(focusIndicator.active).to.be.false; - - el.focus(); - - expect(el.matches(':focus-visible')).to.be.true; - expect(focusIndicator.active).to.be.true; - }); - it('should set pill', async () => { const el = await fixture(html`Button`); @@ -133,889 +96,84 @@ describe('Button', () => { await expect(el).to.be.accessible(); }); - it('should not set popover icon by default', async () => { - const el = await fixture(html`Button`); - - const popoverIcon = getPopoverIcon(el); - - expect(el.popoverIcon).to.be.false; - expect(el.hasAttribute('popover-icon')).to.be.false; - expect(popoverIcon).not.to.be.ok; - }); - - it('should set default popover icon', async () => { - const el = await fixture(html`Button`); - - const popoverIcon = getPopoverIcon(el); - expect(el.popoverIcon).to.be.true; - expect(el.hasAttribute('popover-icon')).to.be.true; - expect(popoverIcon).to.be.ok; - expect(popoverIcon.name).to.equal(tylIconArrowDropDown.name); - await expect(el).to.be.accessible(); - }); - - it('should set popover icon dynamically', async () => { - const el = await fixture(html`Button`); - - await elementUpdated(el); - el.popoverIcon = true; - - const popoverIcon = getPopoverIcon(el); - expect(el.popoverIcon).to.be.true; - expect(el.hasAttribute('popover-icon')).to.be.true; - expect(popoverIcon).to.be.ok; - expect(popoverIcon.name).to.equal(tylIconArrowDropDown.name); - }); - - it('should set dense', async () => { - const el = await fixture(html`Button`); - - expect(el.dense).to.be.true; - expect(el.hasAttribute('dense')).to.be.true; - await expect(el).to.be.accessible(); - }); - - it('should set type to submit', async () => { - const el = await fixture(html`Button`); - - expect(el.type).to.equal('submit'); - expect(el.getAttribute('type')).to.equal('submit'); - await expect(el).to.be.accessible(); - }); - - it('should set type to reset', async () => { - const el = await fixture(html`Button`); - - expect(el.type).to.equal('reset'); - expect(el.getAttribute('type')).to.equal('reset'); - await expect(el).to.be.accessible(); - }); - - it('should be disabled', async () => { - const el = await fixture(html`Button`); - - expect(el.disabled).to.be.true; - expect(el.hasAttribute('disabled')).to.be.true; - expect(el.getAttribute('aria-disabled')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should set disabled dynamically', async () => { - const el = await fixture(html`Button`); - - el.disabled = true; - - let stateLayer = getStateLayer(el); - let focusIndicator = getFocusIndicator(el); - - expect(el.disabled).to.be.true; - expect(el.hasAttribute('disabled')).to.be.true; - expect(el.getAttribute('aria-disabled')).to.equal('true'); - expect(stateLayer).not.to.be.ok; - expect(focusIndicator).not.to.be.ok; - await expect(el).to.be.accessible(); - - el.disabled = false; - - stateLayer = getStateLayer(el); - focusIndicator = getFocusIndicator(el); - - expect(el.disabled).to.be.false; - expect(el.hasAttribute('disabled')).to.be.false; - expect(el.hasAttribute('aria-disabled')).to.be.false; - expect(el.getAttribute('tabindex')).to.equal('0'); - expect(stateLayer).to.be.ok; - expect(focusIndicator).to.be.ok; - }); - - it('should not disable when href is specified', async () => { - const el = await fixture(html`Button`); - - const stateLayer = getStateLayer(el); - const focusIndicator = getFocusIndicator(el); - - expect(el.disabled).to.be.false; - expect(el.hasAttribute('disabled')).to.be.false; - expect(el.hasAttribute('aria-disabled')).to.be.false; - expect(el.getAttribute('tabindex')).to.equal('0'); - expect(stateLayer).to.be.ok; - expect(focusIndicator).to.be.ok; - await expect(el).to.be.accessible(); - }); - - it('should not disable dynamically when href is specified', async () => { - const el = await fixture(html`Button`); - - el.disabled = true; - await elementUpdated(el); - - const stateLayer = getStateLayer(el); - const focusIndicator = getFocusIndicator(el); - - expect(el.disabled).to.be.false; - expect(el.hasAttribute('disabled')).to.be.false; - expect(el.hasAttribute('aria-disabled')).to.be.false; - expect(el.getAttribute('tabindex')).to.equal('0'); - expect(stateLayer).to.be.ok; - expect(focusIndicator).to.be.ok; - await expect(el).to.be.accessible(); - }); - - it('should focus element when focus() is called', async () => { - const el = await fixture(html`Button`); - - el.focus(); - - expect(document.activeElement).to.equal(el); - }); - - it('should focus element when clicked', async () => { - const el = await fixture(html`Button`); - - await clickElement(el); - - expect(document.activeElement).to.equal(el); - }); - - it('should not focus element if clicked when disabled', async () => { - const el = await fixture(html`Button`); - - await clickElement(el); - - expect(document.activeElement).not.to.equal(el); - }); - - it('should dispatch click event when click() is called', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.click(); - - expect(clickSpy.calledOnce).to.be.true; - }); - - it('should dispatch click event when clicked', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - await clickElement(el); - - expect(clickSpy.calledOnce).to.be.true; - }); - - it('should dispatch click event when enter key is pressed', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.focus(); - await pressKey('Enter'); - await elementUpdated(el); - - expect(clickSpy.calledOnce).to.be.true; - }); - - it('should dispatch click event when space key is pressed', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.focus(); - await pressKey(' '); - await elementUpdated(el); - - expect(clickSpy.calledOnce).to.be.true; - }); - - it('should not dispatch click event is keydown event is canceled', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', evt => evt.preventDefault()); - - el.focus(); - await pressKey('Enter'); - await pressKey(' '); - await elementUpdated(el); - - expect(clickSpy).not.to.be.have.been.called; - }); - - it('should not dispatch click event click() called when disabled', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.click(); - - expect(clickSpy.calledOnce).to.be.false; - }); - - it('should not dispatch click event if clicked when disabled', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - await clickElement(el); - - expect(clickSpy.calledOnce).to.be.false; - }); - - it('should not dispatch click event if enter key is pressed when disabled', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.focus(); - await pressKey('Enter'); - await elementUpdated(el); - - expect(clickSpy.calledOnce).to.be.false; - }); - - it('should not dispatch click event if space key is pressed when disabled', async () => { - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.focus(); - await pressKey(' '); - await elementUpdated(el); - - expect(clickSpy.calledOnce).to.be.false; - }); - - it('should render
tag when anchor is set', async () => { - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - expect(el.anchor).to.be.true; - expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; - expect(el.hasAttribute('anchor')).to.be.true; - expect(anchorEl.tabIndex).to.be.equal(-1); - expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should render tag when anchor is set dynamically', async () => { - const el = await fixture(html`Button`); - - el.anchor = true; - - const anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - expect(el.anchor).to.be.true; - expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; - expect(el.hasAttribute('anchor')).to.be.true; - expect(anchorEl.tabIndex).to.be.equal(-1); - expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should render tag when href is set', async () => { - const href = `javascript: console.log('href button')`; - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; - expect(el.anchor).to.be.true; - expect(el.href).to.equal(href); - expect(anchorEl.href).to.equal(href); - expect(anchorEl.tabIndex).to.be.equal(-1); - expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should render tag when href is set dynamically', async () => { - const el = await fixture(html`Button`); - - const href = `javascript: console.log('href button')`; - el.href = href; - - const anchorEl = getAnchorEl(el); - expect(el.anchor).to.be.true; - expect(el.hasAttribute(BASE_BUTTON_CONSTANTS.attributes.ANCHOR)).to.be.true; - expect(el.href).to.equal(href); - expect(anchorEl.href).to.equal(href); - expect(anchorEl.tabIndex).to.be.equal(-1); - expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should set all state on when href is set after', async () => { - const el = await fixture(html`Button`); - - let anchorEl = getAnchorEl(el); - expect(anchorEl).not.to.be.ok; - - el.target = '_blank'; - el.download = 'test'; - el.rel = 'noopener'; - el.href = 'javascript: void(0);'; // Set this last to ensure other anchor state is set first - - anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - expect(anchorEl.getAttribute('target')).to.equal(el.target); - expect(anchorEl.getAttribute('download')).to.equal(el.download); - expect(anchorEl.getAttribute('rel')).to.equal(el.rel); - }); - - it('should render tag when anchor is set and href is set', async () => { - const href = `javascript: console.log('href button')`; - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - expect(el.anchor).to.be.true; - expect(el.hasAttribute('anchor')).to.be.true; - expect(el.href).to.equal(href); - expect(anchorEl.href).to.equal(href); - expect(anchorEl.tabIndex).to.be.equal(-1); - expect(anchorEl.getAttribute('aria-hidden')).to.equal('true'); - await expect(el).to.be.accessible(); - }); - - it('should remove tag when anchor is set to false', async () => { - const el = await fixture(html`Button`); - - el.anchor = false; - - const anchorEl = getAnchorEl(el); - expect(anchorEl).not.to.be.ok; - expect(el.anchor).to.be.false; - expect(el.hasAttribute('anchor')).to.be.false; - }); - - it('should set anchor state when href is removed', async () => { - const el = await fixture(html`Button`); - - el.removeAttribute('href'); - - const anchorEl = getAnchorEl(el); - expect(anchorEl).not.to.be.ok; - expect(el.anchor).to.be.false; - expect(el.hasAttribute('anchor')).to.be.false; - expect(el.hasAttribute('href')).to.be.false; - }); - - it('should click tag when click() is called', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - el.click(); - await elementUpdated(el); - delete window['forgeAnchorTest']; - - expect(clickSpy).to.have.been.calledOnce; - }); - - it('should click tag via mouse', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const testSpy = spy(window as any, 'forgeAnchorTest'); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); - - await clickElement(el); - delete window['forgeAnchorTest']; - - expect(clickSpy).to.have.been.calledOnce; - expect(testSpy).to.have.been.calledOnce; - }); - - it('should not click tag when click event is canceled', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const testSpy = spy(window as any, 'forgeAnchorTest'); - el.addEventListener('click', evt => evt.preventDefault()); - - el.click(); - await elementUpdated(el); - delete window['forgeAnchorTest']; - - expect(testSpy).not.to.have.been.called; - }); - - it('should click tag via keyboard', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const testSpy = spy(window as any, 'forgeAnchorTest'); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); + describe('ButtonComponentDelegate', () => { + it('should create button via delegate', async () => { + const delegate = new ButtonComponentDelegate({ options: { text: 'Button' }}); - el.focus(); - await pressKey('Enter'); - await elementUpdated(el); - delete window['forgeAnchorTest']; + expect(delegate.element).to.be.instanceOf(ButtonComponent); + expect(delegate.element.innerText).to.equal('Button'); + }); - expect(clickSpy).to.have.been.calledOnce; - expect(testSpy).to.have.been.calledOnce; - }); + it('should set variant via delegate', async () => { + const delegate = new ButtonComponentDelegate({ options: { variant: 'raised' }}); - it('should not click tag when enter key is pressed and event is canceled', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const testSpy = spy(window as any, 'forgeAnchorTest'); - el.addEventListener('click', evt => evt.preventDefault()); + expect(delegate.element.variant).to.equal('raised'); + }); - el.focus(); - await pressKey('Enter'); - await elementUpdated(el); - delete window['forgeAnchorTest']; + it('should set type via delegate', async () => { + const delegate = new ButtonComponentDelegate({ options: { type: 'submit' }}); - expect(testSpy).not.to.have.been.called; - }); + expect(delegate.element.type).to.equal('submit'); + }); - it('should not disable ', async () => { - window['forgeAnchorTest'] = () => {}; - const href = `javascript: forgeAnchorTest()`; - const el = await fixture(html`Button`); - const clickSpy = spy(); - el.addEventListener('click', clickSpy); + it('should call click listener via delegate', async () => { + const delegate = new ButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const clickSpy = spy(); - el.click(); - await elementUpdated(el); - delete window['forgeAnchorTest']; + delegate.onClick(clickSpy); + await clickElement(delegate.element); + delegate.element.remove(); - expect(clickSpy).to.have.been.called; - }); + expect(clickSpy).to.be.have.been.calledOnce; + }); - it('should enable button when anchor is set while disabled', async () => { - const el = await fixture(html`Button`); + it('should call focus listener via delegate', async () => { + const delegate = new ButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const focusSpy = spy(); - el.anchor = true; - await elementUpdated(el); + delegate.onFocus(focusSpy); + await clickElement(delegate.element); + delegate.element.remove(); - expect(el.disabled).to.be.false; - }); + expect(focusSpy).to.be.have.been.calledOnce; + }); - it('should enable button when href is set while disabled', async () => { - const el = await fixture(html`Button`); + it('should call blur listener via delegate', async () => { + const delegate = new ButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const blurSpy = spy(); - el.href = 'javascript: void(0);'; - await elementUpdated(el); + delegate.onBlur(blurSpy); + delegate.element.focus(); + await clickElement(document.body); + delegate.element.remove(); - expect(el.disabled).to.be.false; + expect(blurSpy).to.be.have.been.calledOnce; + }); }); - it('should set target', async () => { - const target = '_blank'; - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(el.target).to.equal(target); - expect(el.getAttribute('target')).to.equal(target); - expect(anchorEl.target).to.equal(target); - await expect(el).to.be.accessible(); - }); - - it('should set download', async () => { - const download = 'test'; - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(el.download).to.equal(download); - expect(el.getAttribute('download')).to.equal(download); - expect(anchorEl.download).to.equal(download); + it('should be accessible with aria-label', async () => { + const el = await fixture(html`Button`); await expect(el).to.be.accessible(); }); - - it('should set rel', async () => { - const rel = 'test'; - const el = await fixture(html`Button`); - - const anchorEl = getAnchorEl(el); - expect(el.rel).to.equal(rel); - expect(el.getAttribute('rel')).to.equal(rel); - expect(anchorEl.rel).to.equal(rel); - await expect(el).to.be.accessible(); - }); - - it('should switch from to default', async () => { - const el = await fixture(html`Button`); - - let anchorEl = getAnchorEl(el); - expect(anchorEl).to.be.ok; - - el.href = ''; - anchorEl = getAnchorEl(el); - - expect(anchorEl).not.to.be.ok; - }); - - it('should show popover when click() method is called', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - const toggleSpy = spy(popoverEl as any, 'togglePopover'); - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(toggleSpy).to.have.been.calledOnce; - }); - it('should show popover when clicked', async () => { + it('should be accessible with aria-labelledby', async () => { const el = await fixture(html`
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - const toggleSpy = spy(popoverEl as any, 'togglePopover'); - - await clickElement(buttonEl); - toggleSpy.restore(); - - expect(toggleSpy).to.have.been.calledOnce; - }); - - it('should show popover when enter key is pressed', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - const toggleSpy = spy(popoverEl as any, 'togglePopover'); - - buttonEl.focus(); - await pressKey('Enter'); - await elementUpdated(el); - - expect(toggleSpy).to.have.been.calledOnce; - }); - - it('should hide popover when clicked', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - - await clickElement(buttonEl); - await elementUpdated(popoverEl); - expect(popoverEl.matches(':popover-open')).to.be.true; - - await clickElement(buttonEl); - await elementUpdated(popoverEl); - expect(popoverEl.matches(':popover-open')).to.be.false; - }); - - it('should hide popover when enter key is pressed', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - - buttonEl.focus(); - await pressKey('Enter'); - await elementUpdated(popoverEl); - expect(popoverEl.matches(':popover-open')).to.be.true; - - buttonEl.focus(); - await pressKey('Enter'); - await elementUpdated(el); - expect(popoverEl.matches(':popover-open')).to.be.false; - }); - - it('should not show popover if popovertargetaction is set to hide', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - const showSpy = spy(popoverEl as any, 'showPopover'); - const toggleSpy = spy(popoverEl as any, 'togglePopover'); - - await clickElement(buttonEl); - - expect(showSpy).not.to.have.been.called; - expect(toggleSpy).not.to.have.been.called; - }); - - it('should show popover if popovertargetaction is set to show', async () => { - const el = await fixture(html` -
- Button -
Popover
-
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - const showSpy = spy(popoverEl as any, 'showPopover'); - const toggleSpy = spy(popoverEl as any, 'togglePopover'); - - await clickElement(buttonEl); - - expect(showSpy).to.have.been.calledOnce; - expect(toggleSpy).not.to.have.been.called; - }); - - it('should not hide popover if popovertargetaction is set to show', async () => { - const el = await fixture(html` -
- Button -
Popover
+ Button +
`); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const popoverEl = el.querySelector('[popover]') as HTMLElement; - - await clickElement(buttonEl); - await elementUpdated(popoverEl); - expect(popoverEl.matches(':popover-open')).to.be.true; - - await clickElement(buttonEl); - await elementUpdated(popoverEl); - expect(popoverEl.matches(':popover-open')).to.be.true; - }); - - it('should set form reference', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - expect(buttonEl.form).to.equal(el); - }); - - it('should submit form when click() is called', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(); - el.addEventListener('submit', submitSpy); - await elementUpdated(buttonEl); - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should submit form when clicked by mouse', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(); - el.addEventListener('submit', submitSpy); - - await clickElement(buttonEl); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should submit form when enter key is pressed', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(); - el.addEventListener('submit', submitSpy); - - buttonEl.focus(); - await pressKey('Enter'); - await elementUpdated(el); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should submit form when space key is pressed', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(); - el.addEventListener('submit', submitSpy); - - buttonEl.focus(); - await pressKey(' '); - await elementUpdated(el); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should reset form when click() is called', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const resetSpy = spy(); - el.addEventListener('reset', resetSpy); - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(resetSpy).to.have.been.calledOnce; - }); - - it('should not submit form when click event is canceled', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(evt => evt.preventDefault()); - el.addEventListener('submit', submitSpy); - - const clickSpy = spy(evt => evt.preventDefault()); - buttonEl.addEventListener('click', clickSpy); - - await clickElement(buttonEl); - await elementUpdated(buttonEl); - - expect(clickSpy).to.have.been.called; - expect(submitSpy).not.to.have.been.called; - }); - - it('should set correct form submit event submitter', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(evt => { - expect(evt.submitter).to.equal(buttonEl); - }); - el.addEventListener('submit', submitSpy); - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should set name and value', async () => { - const el = await fixture(html`Button`); - - expect(el.name).to.equal('test'); - expect(el.getAttribute('name')).to.equal('test'); - expect(el.value).to.equal('test-value'); - expect(el.getAttribute('value')).to.equal('test-value'); - - el.name = 'updated-name'; - el.value = 'updated-value' - - expect(el.name).to.equal('updated-name'); - expect(el.getAttribute('name')).to.equal('updated-name'); - expect(el.value).to.equal('updated-value'); - expect(el.getAttribute('value')).to.equal('updated-value'); - }); - - it('should submit form with name', async () => { - const el = await fixture(html` -
- Button -
- `); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - const submitSpy = spy(evt => { - const { name } = evt.submitter as HTMLButtonElement; - const formData = new FormData(el); - - expect(name).to.equal('test'); - expect(buttonEl.value).to.equal('test-value'); - expect(formData.get('test')).to.equal(buttonEl.value); - }); - el.addEventListener('submit', submitSpy); - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(submitSpy).to.have.been.calledOnce; - }); - - it('should close native clicked as submit type', async () => { - const el = await fixture(html` - -
- Button -
-
- `); - - el.showModal(); - - const buttonEl = el.querySelector('forge-button') as IButtonComponent; - - expect(el.open).to.be.true; - - buttonEl.click(); - await elementUpdated(buttonEl); - - expect(el.open).to.be.false; + const button = el.querySelector('forge-button') as IButtonComponent; + await expect(button).to.be.accessible(); }); function getRootEl(el: IButtonComponent): HTMLElement { return el.shadowRoot?.firstElementChild as HTMLElement; } - function getAnchorEl(el: IButtonComponent): HTMLAnchorElement { - return el.shadowRoot?.querySelector('a') as HTMLAnchorElement; - } - function getStateLayer(btn: IButtonComponent): IStateLayerComponent { return btn.shadowRoot?.querySelector('forge-state-layer') as IStateLayerComponent } @@ -1024,10 +182,6 @@ describe('Button', () => { return btn.shadowRoot?.querySelector('forge-focus-indicator') as IFocusIndicatorComponent; } - function getPopoverIcon(btn: IButtonComponent): IIconComponent { - return btn.shadowRoot?.querySelector('slot[name=end] > forge-icon') as IIconComponent; - } - function clickElement(el: HTMLElement): Promise { const { x, y, width, height } = el.getBoundingClientRect(); return sendMouse({ type: 'click', position: [ @@ -1035,8 +189,4 @@ describe('Button', () => { Math.floor(y + window.scrollY + height / 2), ]}); } - - function pressKey(press: ' ' | 'Enter'): Promise { - return sendKeys({ press }); - } }); diff --git a/src/lib/button/button.ts b/src/lib/button/button.ts index 1bfbceae3..820e8c2ea 100644 --- a/src/lib/button/button.ts +++ b/src/lib/button/button.ts @@ -80,10 +80,10 @@ declare global { * @cssproperty --forge-button-border-width - The border-width of the button. * @cssproperty --forge-button-border-style - The border style of the button. * @cssproperty --forge-button-border-color - The border color of the button. - * @cssproperty --forge-button-border-top-left-radius - The border top left radius of the button. - * @cssproperty --forge-button-border-top-right-radius - The border top right radius of the button. - * @cssproperty --forge-button-border-bottom-left-radius - The border bottom left radius of the button. - * @cssproperty --forge-button-border-bottom-right-radius - The border bottom right radius of the button. + * @cssproperty --forge-button-shape-start-start-radius - The shape start start radius of the button. + * @cssproperty --forge-button-shape-start-end-radius - The shape start end radius of the button. + * @cssproperty --forge-button-shape-end-start-radius - The shape end start radius of the button. + * @cssproperty --forge-button-shape-end-end-radius - The shape end end radius of the button. * @cssproperty --forge-button-padding-block - The padding block of the button. * @cssproperty --forge-button-padding-inline - The padding inline of the button. * @cssproperty --forge-button-background - The background color of the button. @@ -136,6 +136,10 @@ declare global { * @csspart root - The root container element. * @csspart focus-indicator - The focus-indicator indicator element. * @csspart state-layer - The state-layer surface element. + * + * @slot - This is a default/unnamed slot for the label text. + * @slot start - Elements to logically render before the label text. + * @slot end - Elements to logically render after the label text. */ @CustomElement({ name: BUTTON_CONSTANTS.elementName, diff --git a/src/lib/calendar/calendar-dom-utils.ts b/src/lib/calendar/calendar-dom-utils.ts index 365d0ad96..dabf28fd7 100644 --- a/src/lib/calendar/calendar-dom-utils.ts +++ b/src/lib/calendar/calendar-dom-utils.ts @@ -174,36 +174,32 @@ export function getHeader(): HTMLElement { element.setAttribute('part', CALENDAR_CONSTANTS.parts.HEADER); const previousButton = document.createElement('forge-icon-button'); - const previousButtonElement = document.createElement('button'); const previousIcon = document.createElement('forge-icon'); const previousTooltip = document.createElement('forge-tooltip'); previousButton.setAttribute('part', CALENDAR_CONSTANTS.parts.PREVIOUS_BUTTON); - previousButtonElement.id = CALENDAR_CONSTANTS.ids.PREVIOUS_BUTTON; - previousButtonElement.type = 'button'; - previousButtonElement.setAttribute('aria-label', 'Previous'); + previousButton.id = CALENDAR_CONSTANTS.ids.PREVIOUS_BUTTON; + previousButton.type = 'button'; + previousButton.setAttribute('aria-label', 'Previous'); previousIcon.setAttribute('name', 'keyboard_arrow_left'); previousTooltip.id = CALENDAR_CONSTANTS.ids.PREVIOUS_BUTTON_TOOLTIP; previousTooltip.setAttribute('aria-hidden', 'true'); previousTooltip.innerText = 'Previous'; - previousButton.appendChild(previousButtonElement); previousButton.appendChild(previousTooltip); - previousButtonElement.appendChild(previousIcon); + previousButton.appendChild(previousIcon); const nextButton = document.createElement('forge-icon-button'); - const nextButtonElement = document.createElement('button'); const nextIcon = document.createElement('forge-icon'); const nextTooltip = document.createElement('forge-tooltip'); nextButton.setAttribute('part', CALENDAR_CONSTANTS.parts.NEXT_BUTTON); - nextButtonElement.id = CALENDAR_CONSTANTS.ids.NEXT_BUTTON; - nextButtonElement.type = 'button'; - nextButtonElement.setAttribute('aria-label', 'Next'); + nextButton.id = CALENDAR_CONSTANTS.ids.NEXT_BUTTON; + nextButton.type = 'button'; + nextButton.setAttribute('aria-label', 'Next'); nextIcon.setAttribute('name', 'keyboard_arrow_right'); nextTooltip.id = CALENDAR_CONSTANTS.ids.NEXT_BUTTON_TOOLTIP; nextTooltip.setAttribute('aria-hidden', 'true'); nextTooltip.innerText = 'Next'; - nextButton.appendChild(nextButtonElement); nextButton.appendChild(nextTooltip); - nextButtonElement.appendChild(nextIcon); + nextButton.appendChild(nextIcon); const monthButton = document.createElement('forge-button'); monthButton.setAttribute('part', CALENDAR_CONSTANTS.parts.MONTH_BUTTON); diff --git a/src/lib/calendar/calendar.scss b/src/lib/calendar/calendar.scss index a5e2f6b30..343ee9039 100644 --- a/src/lib/calendar/calendar.scss +++ b/src/lib/calendar/calendar.scss @@ -1,9 +1,6 @@ @use './mixins'; @use './variables'; -// Sub-component styles -@use '../icon-button/forge-icon-button'; - @include mixins.core-styles; :host { diff --git a/src/lib/checkbox/checkbox.ts b/src/lib/checkbox/checkbox.ts index fe0c2236f..d8c0885f3 100644 --- a/src/lib/checkbox/checkbox.ts +++ b/src/lib/checkbox/checkbox.ts @@ -1,6 +1,6 @@ import { CustomElement, FoundationProperty, attachShadowTemplate, coerceBoolean, isDefined, isString, toggleAttribute } from '@tylertech/forge-core'; import { internals } from '../constants'; -import { BaseNullableFormComponent, IBaseNullableFormComponent } from '../core'; +import { BaseNullableFormComponent, IBaseNullableFormComponent } from '../core/base/base-nullable-form-component'; import { FocusIndicatorComponent } from '../focus-indicator/focus-indicator'; import { ILabelAware } from '../label/label-aware'; import { StateLayerComponent } from '../state-layer/state-layer'; diff --git a/src/lib/circular-progress/circular-progress.scss b/src/lib/circular-progress/circular-progress.scss index 4a5337dd4..8b488c198 100644 --- a/src/lib/circular-progress/circular-progress.scss +++ b/src/lib/circular-progress/circular-progress.scss @@ -108,8 +108,8 @@ svg { @mixin theme($theme) { :host([theme=#{$theme}]) { .forge-circular-progress { - @include override(indicator-color, theme.variable($theme)); - @include override(track-color, theme.variable(#{$theme}-container)); + @include override(indicator-color, theme.variable($theme), value); + @include override(track-color, theme.variable(#{$theme}-container), value); } } } @@ -123,7 +123,7 @@ svg { :host([no-track]) { .forge-circular-progress { - @include override(track-color, transparent); + @include override(track-color, transparent, value); } } diff --git a/src/lib/color-picker/color-picker.html b/src/lib/color-picker/color-picker.html index 89c6265dd..998a07700 100644 --- a/src/lib/color-picker/color-picker.html +++ b/src/lib/color-picker/color-picker.html @@ -71,12 +71,10 @@
- - - Change color format + + + Change color format
diff --git a/src/lib/color-picker/color-picker.scss b/src/lib/color-picker/color-picker.scss index b2ecefae2..eb4b68b91 100644 --- a/src/lib/color-picker/color-picker.scss +++ b/src/lib/color-picker/color-picker.scss @@ -1,9 +1,6 @@ @use '../theme'; @use './mixins'; -// Required component styles -@use '../icon-button/forge-icon-button'; - @include mixins.styles; :host { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 9af928471..25e06e057 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -2,6 +2,14 @@ export const COMPONENT_NAME_PREFIX = 'forge-'; export const KEYSTROKE_DEBOUNCE_THRESHOLD = 500; export const ICON_CLASS_NAME = 'tyler-icons'; export const CDN_BASE_URL = 'https://cdn.forge.tylertech.com/'; + +/** A property symbol that references the `ElementInternals` instance of an element. */ export const internals = Symbol('ElementInternals'); +/** A property symbol that indicates whether or not a `Focusable` element can be focused. */ +export const isFocusable = Symbol('isFocusable'); + export type Theme = 'primary' | 'secondary' | 'tertiary' | 'success' | 'warning' | 'error' | 'info'; + +export type MixinBase = abstract new (...args: any[]) => ExpectedBase; +export type MixinReturn = (abstract new (...args: any[]) => MixinClass) & TBase; diff --git a/src/lib/core/base/base-component.ts b/src/lib/core/base/base-component.ts index 06b3d03f7..aa3189335 100644 --- a/src/lib/core/base/base-component.ts +++ b/src/lib/core/base/base-component.ts @@ -1,67 +1,3 @@ -import { internals } from '../../constants'; - export interface IBaseComponent extends HTMLElement {} -/** Any Custom HTML element. Some elements directly implement this interface, while others implement it via an interface that inherits it. */ export abstract class BaseComponent extends HTMLElement implements IBaseComponent {} - -export interface IBaseFormComponent extends IBaseComponent { - value: T; - disabled: boolean; - readonly: boolean; - name: string; - - readonly form: HTMLFormElement | null; - readonly labels: NodeList; - readonly [internals]: ElementInternals; - - formResetCallback(): void; - formStateRestoreCallback(state: unknown, reason: 'restore' | 'autocomplete'): void; - formDisabledCallback(isDisabled: boolean): void; -} - -/** Any form associated Custom HTML element. */ -export abstract class BaseFormComponent extends BaseComponent implements IBaseFormComponent { - public static formAssociated = true; - - public abstract value: T; - public abstract disabled: boolean; - public abstract readonly: boolean; - public abstract name: string; - - public abstract get form(): HTMLFormElement | null; - public abstract get labels(): NodeList; - public abstract get [internals](): ElementInternals; - - public abstract formResetCallback(): void; - public abstract formStateRestoreCallback(state: unknown, reason: 'restore' | 'autocomplete'): void; - public abstract formDisabledCallback(isDisabled: boolean): void; -} - -export interface IBaseNullableFormComponent extends IBaseFormComponent { - required: boolean; - - readonly validity: ValidityState; - readonly validationMessage: string; - readonly willValidate: boolean; - - checkValidity(): boolean; - reportValidity(): boolean; - setCustomValidity(error: string): void; -} - -export abstract class BaseNullableFormComponent extends BaseFormComponent { - public abstract required: boolean; - - public abstract get validity(): ValidityState; - public abstract get validationMessage(): string; - public abstract get willValidate(): boolean; - - // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 - // Replace with this.internals.validity.customError when resolved. - protected _hasCustomValidityError = false; - - public abstract checkValidity(): boolean; - public abstract reportValidity(): boolean; - public abstract setCustomValidity(error: string): void; -} diff --git a/src/lib/core/base/base-focusable-component.test.ts b/src/lib/core/base/base-focusable-component.test.ts new file mode 100644 index 000000000..997b80b1b --- /dev/null +++ b/src/lib/core/base/base-focusable-component.test.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Adapted and influenced from [Material Web](https://github.com/material-components/material-web). + * The original source code can be found at: [GitHub](https://github.com/material-components/material-web/blob/main/labs/behaviors/focusable_test.ts) + */ + +import { expect } from '@esm-bundle/chai'; +import { fixture, html } from '@open-wc/testing'; +import { CustomElement } from '@tylertech/forge-core'; + +import { isFocusable } from '../../constants'; +import { WithFocusable } from './base-focusable-component'; +import { BaseComponent } from './base-component'; + + +describe('WithFocusable', () => { + @CustomElement({ name: 'test-focusable' }) + class TestFocusable extends WithFocusable(BaseComponent) { + public static get observedAttributes(): string[] { + return ['tabindex']; + } + } + + async function setupTest(): Promise { + return await fixture(html``); + } + + it('should set isFocusable to true by default', async () => { + const element = await setupTest(); + + expect(element[isFocusable]).to.be.true; + }); + + it('should set tabindex="0" when isFocusable is true', async () => { + const element = await setupTest(); + + expect(element.tabIndex).to.equal(0); + }); + + it('should set tabindex="-1" when isFocusable is false', async () => { + const element = await setupTest(); + + element[isFocusable] = false; + + expect(element.tabIndex).to.equal(-1); + }); + + it('should not override user-set tabindex="0" when isFocusable is false', async () => { + const element = await setupTest(); + + element[isFocusable] = false; + element.tabIndex = 0; + + expect(element[isFocusable]).to.be.false; + expect(element.tabIndex).to.equal(0); + }); + + it('should not override user-set tabindex="-1" when isFocusable is true', async () => { + const element = await setupTest(); + + element.tabIndex = -1; + + expect(element[isFocusable]).to.be.true; + expect(element.tabIndex).to.equal(-1); + }); + + it('should restore default tabindex when user-set tabindex attribute is removed', async () => { + const element = await setupTest(); + + element.tabIndex = -1; + element.removeAttribute('tabindex'); + + expect(element.tabIndex).to.equal(0); + }); +}); diff --git a/src/lib/core/base/base-focusable-component.ts b/src/lib/core/base/base-focusable-component.ts new file mode 100644 index 000000000..e078794aa --- /dev/null +++ b/src/lib/core/base/base-focusable-component.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2023 Google LLC + * SPDX-License-Identifier: Apache-2.0 + * + * Adapted and influenced from [Material Web](https://github.com/material-components/material-web). + * The original source code can be found at: [GitHub](https://github.com/material-components/material-web/blob/main/labs/behaviors/focusable.ts) + */ + +import { isFocusable, MixinBase, MixinReturn } from '../../constants'; +import { BaseComponent, IBaseComponent } from './base-component'; + +/** + * An element that can enable and disable `tabindex` focusability. + */ +export interface IBaseFocusableComponent extends IBaseComponent { + /** + * Whether or not the element can be focused. Defaults to true. Set to false + * to disable focusing (unless a user has set a `tabindex`). + */ + [isFocusable]: boolean; + + connectedCallback(): void; + attributeChangedCallback(name: string, oldValue: string, newValue: string): void; +} + +const _privateIsFocusable = Symbol('privateIsFocusable'); +const _externalTabIndex = Symbol('externalTabIndex'); +const _isUpdatingTabIndex = Symbol('isUpdatingTabIndex'); +const _updateTabIndex = Symbol('updateTabIndex'); + +/** + * Mixes in focusable functionality into a base component. + * + * @param base The base component to mix into. + * @returns The mixed-in base component. + */ +export function WithFocusable>(base: T): MixinReturn { + abstract class FocusableComponent extends base implements IBaseFocusableComponent { + public get [isFocusable](): boolean { + return this[_privateIsFocusable]; + } + + /** + * Whether or not the element can be focused. + * + * Set this from inheriting components **instead** of directly manipulating `tabIndex`. + */ + public set [isFocusable](value: boolean) { + if (this[isFocusable] === value) { + return; + } + this[_privateIsFocusable] = value; + this[_updateTabIndex](); + } + + private [_privateIsFocusable] = false; + private [_externalTabIndex]: number | null = null; // Allows for external tabIndex to be stored when internal tabIndex is set to -1 + private [_isUpdatingTabIndex] = false; // Allows for internal tabIndex to be set without triggering attributeChangedCallback + + public connectedCallback(): void { + // This must be set in the connectedCallback to avoid sprouting a tabindex attribute on the host from the ctor + this[isFocusable] = true; + } + + public attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void { + if (name !== 'tabindex' || this[_isUpdatingTabIndex]) { + return; + } + + if (!this.hasAttribute('tabindex')) { + // User removed the attribute, can now use internal tabIndex + this[_externalTabIndex] = null; + this[_updateTabIndex](); + return; + } + + this[_externalTabIndex] = this.tabIndex; + } + + private [_updateTabIndex](): void { + const internalTabIndex = this[isFocusable] ? 0 : -1; + const computedTabIndex = this[_externalTabIndex] ?? internalTabIndex; + // const computedTabIndex = internalTabIndex === 0 ? this[_externalTabIndex] ?? internalTabIndex : internalTabIndex; + + this[_isUpdatingTabIndex] = true; + this.tabIndex = computedTabIndex; + this[_isUpdatingTabIndex] = false; + } + } + return FocusableComponent; +} + +/** + * Provides focusable functionality for an element. + * + * Elements can enable and disable their focusability with the `isFocusable` + * symbol property. **Use this instead of changing `tabIndex` directly.** + * + * This will preserve externally-set tabindices. If an element sets `tabindex="-1"`, + * but a user sets `tabindex="0"`, it will still be focusable. + * + * To remove user overrides and restore focus control to the element, remove the `tabindex` attribute. + */ +export abstract class BaseFocusableComponent extends WithFocusable(BaseComponent) implements IBaseFocusableComponent {} diff --git a/src/lib/core/base/base-form-component.ts b/src/lib/core/base/base-form-component.ts new file mode 100644 index 000000000..580244ea7 --- /dev/null +++ b/src/lib/core/base/base-form-component.ts @@ -0,0 +1,46 @@ +import { internals } from '../../constants'; +import { BaseComponent, IBaseComponent } from './base-component'; +import { IBaseFocusableComponent, WithFocusable } from './base-focusable-component'; + +export interface IBaseFormComponent extends IBaseComponent { + value: T; + disabled: boolean; + readonly: boolean; + name: string; + + readonly form: HTMLFormElement | null; + readonly labels: NodeList; + readonly [internals]: ElementInternals; + + formResetCallback(): void; + formStateRestoreCallback(state: unknown, reason: 'restore' | 'autocomplete'): void; + formDisabledCallback(isDisabled: boolean): void; +} + +/** + * Any form associated Custom HTML element. + */ +export abstract class BaseFormComponent extends BaseComponent implements IBaseFormComponent { + public static formAssociated = true; + + public abstract value: T; + public abstract disabled: boolean; + public abstract readonly: boolean; + public abstract name: string; + + public abstract get form(): HTMLFormElement | null; + public abstract get labels(): NodeList; + public abstract get [internals](): ElementInternals; + + public abstract formResetCallback(): void; + public abstract formStateRestoreCallback(state: unknown, reason: 'restore' | 'autocomplete'): void; + public abstract formDisabledCallback(isDisabled: boolean): void; +} + + +export interface IBaseFocusableFormComponent extends IBaseFormComponent, IBaseFocusableComponent {} + +/** + * Any form associated Custom HTML element that focus on the host element. + */ +export abstract class BaseFocusableFormComponent extends WithFocusable(BaseFormComponent) implements IBaseFocusableFormComponent {} diff --git a/src/lib/core/base/base-nullable-form-component.ts b/src/lib/core/base/base-nullable-form-component.ts new file mode 100644 index 000000000..85f2dfd65 --- /dev/null +++ b/src/lib/core/base/base-nullable-form-component.ts @@ -0,0 +1,37 @@ +import { IBaseFocusableComponent, WithFocusable } from './base-focusable-component'; +import { BaseFormComponent, IBaseFormComponent } from './base-form-component'; + +export interface IBaseNullableFormComponent extends IBaseFormComponent { + required: boolean; + + readonly validity: ValidityState; + readonly validationMessage: string; + readonly willValidate: boolean; + + checkValidity(): boolean; + reportValidity(): boolean; + setCustomValidity(error: string): void; +} + +export abstract class BaseNullableFormComponent extends BaseFormComponent { + public abstract required: boolean; + + public abstract get validity(): ValidityState; + public abstract get validationMessage(): string; + public abstract get willValidate(): boolean; + + // Needed for Safari, see https://bugs.webkit.org/show_bug.cgi?id=261432 + // Replace with this.internals.validity.customError when resolved. + protected _hasCustomValidityError = false; + + public abstract checkValidity(): boolean; + public abstract reportValidity(): boolean; + public abstract setCustomValidity(error: string): void; +} + +export interface IBaseFocusableNullableFormComponent extends IBaseFormComponent, IBaseFocusableComponent {} + +/** + * Any form associated Custom HTML element that focus on the host element. + */ +export abstract class BaseFocusableNullableFormComponent extends WithFocusable(BaseFormComponent) implements IBaseFocusableNullableFormComponent {} diff --git a/src/lib/core/styles/_utils.scss b/src/lib/core/styles/_utils.scss index 59f33da5a..a06d80b5e 100644 --- a/src/lib/core/styles/_utils.scss +++ b/src/lib/core/styles/_utils.scss @@ -114,6 +114,15 @@ /// @param {Boolean} $type - The type of token this is. Valid values are `token` and `value`. /// @mixin override($module, $module-tokens, $token, $token-or-value, $type: token) { + @if $type == 'token' { + @if not map.get($module-tokens, $token) { + @error 'Invalid token "#{$token}" for module "#{$module}"'; + } + @if not map.get($module-tokens, $token-or-value) { + @error 'Invalid override token "#{$token-or-value}" for module "#{$module}"'; + } + } + // When the "token" type is specified, we output a module-level CSS custom property override // that points to the provided token. This ensures that the module is taking ownership of the // token value (which breaks the global cascading of internal tokens) and the module can choose diff --git a/src/lib/core/styles/tokens/button/_tokens.scss b/src/lib/core/styles/tokens/button/_tokens.scss index 6f3851e64..bb6c6c586 100644 --- a/src/lib/core/styles/tokens/button/_tokens.scss +++ b/src/lib/core/styles/tokens/button/_tokens.scss @@ -1,5 +1,4 @@ @use 'sass:map'; -@use '../color-palette'; @use '../../animation'; @use '../../border'; @use '../../elevation'; @@ -26,10 +25,10 @@ $tokens: ( border-width: utils.module-val(button, border-width, medium), border-style: utils.module-val(button, border-style, none), border-color: utils.module-val(button, border-color, currentColor), - border-top-left-radius: utils.module-ref(button, border-top-left-radius, shape), - border-top-right-radius: utils.module-ref(button, border-top-right-radius, shape), - border-bottom-left-radius: utils.module-ref(button, border-bottom-left-radius, shape), - border-bottom-right-radius: utils.module-ref(button, border-bottom-right-radius, shape), + shape-start-start-radius: utils.module-ref(button, shape-start-start-radius, shape), + shape-start-end-radius: utils.module-ref(button, shape-start-end-radius, shape), + shape-end-start-radius: utils.module-ref(button, shape-end-start-radius, shape), + shape-end-end-radius: utils.module-ref(button, shape-end-end-radius, shape), padding-block: utils.module-ref(button, padding-block, padding), padding-inline: utils.module-ref(button, padding-inline, padding), background: utils.module-val(button, background, transparent), @@ -44,22 +43,6 @@ $tokens: ( transition-duration: utils.module-val(button, transition-duration, animation.variable(duration-short3)), transition-timing: utils.module-val(button, transition-timing, animation.variable(easing-standard)), - // Raised - raised-background: utils.module-ref(button, raised-background, primary-color), - raised-disabled-background: utils.module-ref(button, raised-disabled-background, disabled-color), - raised-color: utils.module-val(button, raised-color, theme.variable(on-primary)), - raised-disabled-color: utils.module-ref(button, raised-disabled-color, disabled-text-color), - raised-shadow: utils.module-val(button, raised-shadow, elevation.value(2)), - raised-hover-shadow: utils.module-val(button, raised-hover-shadow, elevation.value(4)), - raised-active-shadow: utils.module-val(button, raised-active-shadow, elevation.value(8)), - raised-disabled-shadow: utils.module-val(button, raised-disabled-shadow, none), - - // Flat - flat-background: utils.module-ref(button, flat-background, primary-color), - flat-disabled-background: utils.module-ref(button, flat-disabled-background, disabled-color), - flat-color: utils.module-val(button, flat-color, theme.variable(on-primary)), - flat-disabled-color: utils.module-ref(button, flat-disabled-color, disabled-text-color), - // Outlined outlined-background: utils.module-ref(button, outlined-background, background), outlined-color: utils.module-ref(button, outlined-color, primary-color), @@ -67,6 +50,24 @@ $tokens: ( outlined-border-style: utils.module-val(button, outlined-border-style, solid), outlined-border-color: utils.module-ref(button, outlined-border-color, primary-color), + // Tonal + tonal-background: utils.module-val(button, tonal-background, theme.variable(primary-container)), + tonal-disabled-background: utils.module-ref(button, tonal-disabled-background, disabled-color), + tonal-color: utils.module-val(button, flat-color, theme.variable(on-primary-container)), + tonal-disabled-color: utils.module-ref(button, tonal-disabled-color, disabled-text-color), + + // Filled + filled-background: utils.module-ref(button, filled-background, primary-color), + filled-disabled-background: utils.module-ref(button, filled-disabled-background, disabled-color), + filled-color: utils.module-val(button, filled-color, theme.variable(on-primary)), + filled-disabled-color: utils.module-ref(button, filled-disabled-color, disabled-text-color), + + // Raised + raised-shadow: utils.module-val(button, raised-shadow, elevation.value(2)), + raised-hover-shadow: utils.module-val(button, raised-hover-shadow, elevation.value(4)), + raised-active-shadow: utils.module-val(button, raised-active-shadow, elevation.value(8)), + raised-disabled-shadow: utils.module-val(button, raised-disabled-shadow, none), + // Link link-color: utils.module-ref(button, link-color, primary-color), link-text-decoration: utils.module-val(button, link-text-decoration, underline), diff --git a/src/lib/core/styles/tokens/icon-button/_tokens.scss b/src/lib/core/styles/tokens/icon-button/_tokens.scss new file mode 100644 index 000000000..a6350b675 --- /dev/null +++ b/src/lib/core/styles/tokens/icon-button/_tokens.scss @@ -0,0 +1,101 @@ +@use 'sass:map'; +@use '../../utils'; +@use '../../animation'; +@use '../../shape'; +@use '../../spacing'; +@use '../../elevation'; +@use '../../typography'; +@use '../../theme'; + +$tokens: ( + // General + display: utils.module-val(icon-button, display, inline-flex), + size: utils.module-val(icon-button, size, 48px), + gap: utils.module-val(icon-button, spacing, 0), + icon-color: utils.module-val(icon-button, icon-color, inherit), + background-color: utils.module-val(icon-button, background-color, none), + icon-size: utils.module-val(icon-button, icon-size, typography.font-size-relative('1500')), + cursor: utils.module-val(icon-button, cursor, pointer), + padding: utils.module-val(icon-button, padding, spacing.variable(xxsmall)), + border: utils.module-val(icon-button, border, none), + shadow: utils.module-val(icon-button, shadow, none), + + // Animation + transition-duration: utils.module-val(icon-button, transition-duration, animation.variable(duration-short3)), + transition-timing: utils.module-val(icon-button, transition-timing, animation.variable(easing-standard)), + + // Shape + shape: utils.module-val(icon-button, shape, shape.variable(full)), + shape-start-start: utils.module-ref(icon-button, shape-start-start, shape), + shape-start-end: utils.module-ref(icon-button, shape-start-end, shape), + shape-end-start: utils.module-ref(icon-button, shape-end-start, shape), + shape-end-end: utils.module-ref(icon-button, shape-end-end, shape), + shape-squared: utils.module-val(icon-button, shape-squared, shape.variable(medium)), + + // Outlined + outlined-border-width: utils.module-val(icon-button, outlined-border-width, 1px), + outlined-border-style: utils.module-val(icon-button, outlined-border-style, solid), + outlined-border-color: utils.module-ref(icon-button, outlined-border-color, icon-color), + + // Tonal + tonal-icon-color: utils.module-val(icon-button, tonal-icon-color, theme.variable(on-primary-container)), + tonal-background-color: utils.module-val(icon-button, tonal-background-color, theme.variable(primary-container)), + + // Filled + filled-icon-color: utils.module-val(icon-button, filled-icon-color, theme.variable(on-primary)), + filled-background-color: utils.module-val(icon-button, filled-background-color, theme.variable(primary)), + + // Raised + raised-shadow: utils.module-val(icon-button, raised-shadow, elevation.value(2)), + raised-hover-shadow: utils.module-val(icon-button, raised-hover-shadow, elevation.value(4)), + raised-active-shadow: utils.module-val(icon-button, raised-active-shadow, elevation.value(8)), + raised-disabled-shadow: utils.module-val(icon-button, raised-disabled-shadow, none), + + // Density - small + density-small-size: utils.module-val(icon-button, density-small-size, 24px), + density-small-padding: utils.module-val(icon-button, density-small-padding, spacing.variable(xxxsmall)), + density-small-icon-size: utils.module-val(icon-button, density-small-icon-size, typography.font-size-relative('1125')), + + // Density - medium + density-medium-size: utils.module-val(icon-button, density-medium-size, 36px), + density-medium-padding: utils.module-val(icon-button, density-medium-padding, spacing.variable(xxsmall)), + + // Density - large (default) + density-large-size: utils.module-ref(icon-button, density-large-size, size), + + // Toggle (on) + toggle-on-icon-color: utils.module-val(icon-button, toggle-on-icon-color, theme.variable(primary)), + + // Toggle (on) outlined + outlined-toggle-on-background-color: utils.module-val(icon-button, outlined-toggle-on-background-color, theme.variable(primary-container-low)), + outlined-toggle-on-icon-color: utils.module-val(icon-button, outlined-toggle-on-icon-color, theme.variable(primary)), + + // Toggle tonal + tonal-toggle-background-color: utils.module-val(icon-button, tonal-toggle-background-color, theme.variable(surface-container-low)), + + // Toggle (on) tonal + tonal-toggle-on-background-color: utils.module-val(icon-button, tonal-toggle-on-background-color, theme.variable(primary-container)), + tonal-toggle-on-icon-color: utils.module-val(icon-button, tonal-toggle-on-icon-color, theme.variable(primary)), + + // Toggle filled + filled-toggle-background-color: utils.module-val(icon-button, filled-toggle-background-color, theme.variable(surface-container-low)), + filled-toggle-icon-color: utils.module-val(icon-button, filled-toggle-background-color, theme.variable(primary)), + + // Toggle (on) filled + filled-toggle-on-background-color: utils.module-val(icon-button, filled-toggle-on-background-color, theme.variable(primary)), + filled-toggle-on-icon-color: utils.module-val(icon-button, filled-toggle-on-icon-color, theme.variable(on-primary)), + + // Disabled + disabled-cursor: utils.module-val(icon-button, disabled-cursor, not-allowed), + disabled-opacity: utils.module-val(icon-button, disabled-opacity, theme.emphasis(medium-low)), + + // Popover icon + popover-icon-padding: utils.module-val(icon-button, popover-icon-padding, spacing.variable(xsmall)), + + // Focus indicator + focus-indicator-color: utils.module-val(icon-button, focus-indicator-color, theme.variable(primary)) +) !default; + +@function get($key) { + @return map.get($tokens, $key); +} diff --git a/src/lib/core/styles/tokens/theme/_token-utils.scss b/src/lib/core/styles/tokens/theme/_token-utils.scss index 11dc021b7..07f75c5ee 100644 --- a/src/lib/core/styles/tokens/theme/_token-utils.scss +++ b/src/lib/core/styles/tokens/theme/_token-utils.scss @@ -18,8 +18,9 @@ $surface-tone: color-utils.tone($surface); // The container colors are the provided color mixed with the surface color at lower emphasis levels + $container-low: theme-utils.hexify($color, $surface, color-emphasis.value(if($surface-tone == 'light', lower, low))); $container: theme-utils.hexify($color, $surface, color-emphasis.value(if($surface-tone == 'light', low, medium-low))); - // $container-high: theme-utils.hexify($color, $surface, color-emphasis.value(if($surface-tone == 'light', medium-low, medium))); + $container-high: theme-utils.hexify($color, $surface, color-emphasis.value(if($surface-tone == 'light', medium-low, medium))); // The on-color is the contrast color against the provided color $on-color: theme-utils.contrast($color); @@ -27,15 +28,18 @@ // The on-container colors are the contrast color for the provided color mixed with the // container color at a lower emphasis to let the contrast color bleed through for // increased contrast against the lower emphasis container color + $on-container-low: theme-utils.hexify($color, theme-utils.contrast($container-low), color-emphasis.value(medium-low)); $on-container: theme-utils.hexify($color, theme-utils.contrast($container), color-emphasis.value(low)); - // $on-container-high: theme-utils.contrast($container-high); + $on-container-high: theme-utils.contrast($container-high); @return ( #{$name}: $color, + #{$name}-container-low: $container-low, #{$name}-container: $container, - // #{$name}-container-high: $container-high, + #{$name}-container-high: $container-high, on-#{$name}: $on-color, + on-#{$name}-container-low: $on-container-low, on-#{$name}-container: $on-container, - // on-#{$name}-container-high: $on-container-high + on-#{$name}-container-high: $on-container-high ); } diff --git a/src/lib/date-picker/base/base-date-picker-utils.ts b/src/lib/date-picker/base/base-date-picker-utils.ts index ba8f8957d..ba7e2f8dd 100644 --- a/src/lib/date-picker/base/base-date-picker-utils.ts +++ b/src/lib/date-picker/base/base-date-picker-utils.ts @@ -1,19 +1,15 @@ export function createToggleElement(iconName: string): HTMLElement { const iconButtonElement = document.createElement('forge-icon-button'); + iconButtonElement.type = 'button'; + iconButtonElement.tabIndex = -1; + iconButtonElement.setAttribute('aria-label', 'Toggle calendar'); iconButtonElement.slot = 'trailing'; - iconButtonElement.dense = true; - iconButtonElement.densityLevel = 3; + iconButtonElement.density = 'medium'; iconButtonElement.style.marginRight = '4px'; - const buttonElement = document.createElement('button'); - buttonElement.type = 'button'; - buttonElement.tabIndex = -1; - buttonElement.setAttribute('aria-label', 'Toggle calendar'); - iconButtonElement.appendChild(buttonElement); - const iconElement = document.createElement('forge-icon'); iconElement.name = iconName; - buttonElement.appendChild(iconElement); + iconButtonElement.appendChild(iconElement); return iconButtonElement; } diff --git a/src/lib/file-picker/_mixins.scss b/src/lib/file-picker/_mixins.scss index d6863a67c..ec7f82c38 100644 --- a/src/lib/file-picker/_mixins.scss +++ b/src/lib/file-picker/_mixins.scss @@ -43,7 +43,7 @@ display: none; } - .forge-file-picker__button > button { + .forge-file-picker__button { @include button-compact; } } diff --git a/src/lib/focus-indicator/_core.scss b/src/lib/focus-indicator/_core.scss index 9599b5511..27a1cfab2 100644 --- a/src/lib/focus-indicator/_core.scss +++ b/src/lib/focus-indicator/_core.scss @@ -18,7 +18,7 @@ } @mixin circular { - @include override(shape, 50%); + @include override(shape, 50%, value); } @mixin outward { diff --git a/src/lib/forge.scss b/src/lib/forge.scss index 81ea3968f..c43cf550a 100644 --- a/src/lib/forge.scss +++ b/src/lib/forge.scss @@ -6,7 +6,6 @@ // Required global component styles @use './floating-action-button/forge-floating-action-button'; -@use './icon-button/forge-icon-button'; @use './table/forge-table'; @use './tooltip/forge-tooltip'; @use './ripple/forge-ripple'; diff --git a/src/lib/icon-button/_configuration.scss b/src/lib/icon-button/_configuration.scss new file mode 100644 index 000000000..f51b8640c --- /dev/null +++ b/src/lib/icon-button/_configuration.scss @@ -0,0 +1,11 @@ +@use './token-utils' as *; + +$host-tokens: [display disabled-cursor]; + +@mixin host-configuration { + @include tokens($includes: $host-tokens); +} + +@mixin configuration { + @include tokens($excludes: $host-tokens); +} diff --git a/src/lib/icon-button/_core.scss b/src/lib/icon-button/_core.scss new file mode 100644 index 000000000..740a69d2e --- /dev/null +++ b/src/lib/icon-button/_core.scss @@ -0,0 +1,140 @@ +@use './token-utils' as *; + +@mixin host { + display: #{token(display)}; + position: relative; + outline: none; + -webkit-tap-highlight-color: transparent; +} + +@mixin base { + position: relative; + z-index: 0; + + display: #{token(display)}; + align-items: center; + justify-content: center; + gap: #{token(gap)}; + + box-sizing: border-box; + height: #{token(density-large-size)}; + min-width: #{token(density-large-size)}; + border: #{token(border)}; + 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: #{token(padding)}; + box-shadow: #{token(shadow)}; + + color: #{token(icon-color)}; + background: #{token(background-color)}; + font-size: #{token(icon-size)}; + cursor: #{token(cursor)}; + user-select: none; + + transition-property: box-shadow, background; + transition-duration: #{token(transition-duration)}; + transition-timing-function: #{token(transition-timing)}; +} + +@mixin slotted-start-end { + font-size: #{token(icon-size)}; + height: #{token(icon-size)}; + width: #{token(icon-size)}; + font-weight: inherit; +} + +@mixin anchor-base { + position: absolute; + inset: 0; + text-decoration: none; +} + +@mixin host-disabled { + cursor: #{token(disabled-cursor)}; +} + +@mixin disabled { + pointer-events: none; + opacity: #{token(disabled-opacity)}; +} + +@mixin outlined { + border-width: #{token(outlined-border-width)}; + border-style: #{token(outlined-border-style)}; + border-color: #{token(outlined-border-color)}; +} + +@mixin tonal { + @include override(icon-color, tonal-icon-color); + @include override(background-color, tonal-background-color); +} + +@mixin filled { + @include override(icon-color, filled-icon-color); + @include override(background-color, filled-background-color); +} + +@mixin raised { + @include override(shadow, raised-shadow); + + &:hover { + @include override(raised-shadow, raised-hover-shadow); + } + + &:active { + @include override(raised-shadow, raised-active-shadow); + } +} + +@mixin raised-disabled { + @include override(raised-shadow, raised-disabled-shadow); +} + +@mixin density-small { + @include override(size, density-small-size); + @include override(icon-size, density-small-icon-size); + @include override(padding, density-small-padding); +} + +@mixin density-small-slotted { + font-size: #{token(density-small-icon-size)}; +} + +@mixin density-medium { + @include override(size, density-medium-size); + @include override(padding, density-medium-padding); +} + +@mixin toggle-on-icon { + @include override(icon-color, toggle-on-icon-color); +} + +@mixin toggle-outlined { + // @include override(icon-color, outlined-toggle-icon-color); +} + +@mixin toggle-on-outlined { + @include override(background-color, outlined-toggle-on-background-color); + @include override(icon-color, outlined-toggle-on-icon-color); +} + +@mixin toggle-tonal { + @include override(background-color, tonal-toggle-background-color); +} + +@mixin toggle-on-tonal { + @include override(icon-color, tonal-toggle-on-icon-color); + @include override(background-color, tonal-toggle-on-background-color); +} + +@mixin toggle-filled { + @include override(icon-color, filled-toggle-icon-color); + @include override(background-color, filled-toggle-background-color); +} + +@mixin toggle-on-filled { + @include override(icon-color, filled-toggle-on-icon-color); + @include override(background-color, filled-toggle-on-background-color); +} diff --git a/src/lib/icon-button/_mixins.scss b/src/lib/icon-button/_mixins.scss deleted file mode 100644 index 7eff0a0ff..000000000 --- a/src/lib/icon-button/_mixins.scss +++ /dev/null @@ -1,340 +0,0 @@ -// -// Copyright 2018 Google Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -// - -// Selector '.forge-*' should only be used in this project. -// stylelint-disable selector-class-pattern - -@use 'sass:math'; -@use '@material/density/functions' as mdc-density-functions; -@use '@material/feature-targeting/feature-targeting' as mdc-feature-targeting; -@use '@material/ripple/ripple' as mdc-ripple; -@use '@material/ripple/ripple-theme' as mdc-ripple-theme; -@use '@material/rtl/mixins' as mdc-rtl; -@use '@material/theme/theme' as mdc-theme; -@use './variables'; -@use '../theme'; -@use '../theme/theme-values'; -@use '../app-bar/variables' as app-bar-variables; - -@mixin core-styles($query: mdc-feature-targeting.all()) { - @include without-ripple($query); - @include ripple($query); - - // FORGE (new): additional forge-specific styles - @include styles; -} - -@mixin styles() { - forge-icon-button { - @include host; - } - - .forge-icon-button { - &--on { - @include ink-color(primary); - - - &::before { - @include mdc-theme.property(background-color, primary); - - opacity: 0.08; - } - } - - &--dense { - @include density(-5); - - padding: 0; - } - - &--dense-1 { - @include density(-1); - } - - &--dense-2 { - @include density(-2); - } - - &--dense-3 { - @include density(-3); - } - - &--dense-4 { - @include density(-4); - } - - &--dense-5 { - @include density(-5); - } - - &--dense-6 { - @include density(-6); - } - - &--with-badge { - forge-badge { - @include theme.z-index(surface); - - pointer-events: none; - --forge-badge-max-width: 32px; - --forge-badge-border: 1px solid transparent; - } - - forge-badge[app-bar-context] { - --forge-badge-border: 2px solid var(--forge-app-bar-theme-background); - } - } - } -} - -@mixin host() { - position: relative; - display: inline-block; - overflow: visible; -} - -@mixin without-ripple($query: mdc-feature-targeting.all()) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - // postcss-bem-linter: define icon-button - .forge-icon-button { - @include base_($query: $query); - @include density(0, $query: $query); - } - - .forge-icon-button__icon { - @include mdc-feature-targeting.targets($feat-structure) { - display: inline-block; - } - - // stylelint-disable-next-line plugin/selector-bem-pattern - &.forge-icon-button__icon--on { - @include mdc-feature-targeting.targets($feat-structure) { - display: none; - } - } - } - - .forge-icon-button--on { - .forge-icon-button__icon { - @include mdc-feature-targeting.targets($feat-structure) { - display: none; - } - - // stylelint-disable-next-line plugin/selector-bem-pattern - &.forge-icon-button__icon--on { - @include mdc-feature-targeting.targets($feat-structure) { - display: inline-block; - } - } - } - } - // postcss-bem-linter: end -} - -@mixin ripple($query: mdc-feature-targeting.all()) { - @include mdc-ripple.common($query); // COPYBARA_COMMENT_THIS_LINE - - .forge-icon-button { - @include mdc-ripple.surface($query: $query); - @include mdc-ripple.radius-unbounded($query: $query); - @include mdc-ripple-theme.states(on-surface, $query: $query); // FORGE (modify): use on-surface for ripple to support dark theme - } -} - -/// -/// Sets the density scale for icon button. -/// -/// @param {Number | String} $density-scale - Density scale value for component. -/// Supported density scale values range from `-5` to `0`, with `0` being the default. -/// -@mixin density($density-scale, $query: mdc-feature-targeting.all()) { - $size: mdc-density-functions.prop-value( - $density-config: variables.$density-config, - $density-scale: $density-scale, - $property-name: size, - ); - - @include size($size, $query: $query); -} - -/// -/// Sets the size of the icon-button. -/// -/// @param {Number} $size - Size value for icon-button. -/// Size will set the width, height, and padding for the overall component. -/// -@mixin size($size, $query: mdc-feature-targeting.all()) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - @include mdc-feature-targeting.targets($feat-structure) { - width: $size; - height: $size; - padding: ($size - variables.$icon-size) * 0.5; - } -} - -/// -/// Sets the width, height and padding of icon button. Also changes the size of -/// the icon itself based on button size. -/// -/// @param {Number} $width - Width value for icon-button. -/// @param {Number} $height - Height value for icon-button. (default: $width) -/// @param {Number} $padding - Padding value for icon-button. (default: max($width, $height) / 2) -/// @deprecated -/// This mixin provides too much of low level customization. -/// Please use mdc-icon-button-size instead. -/// -@mixin icon-size( - $width, - $height: $width, - $padding: math.max($width, $height) * 0.5, - $query: mdc-feature-targeting.all() -) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - @include mdc-feature-targeting.targets($feat-structure) { - width: $width + $padding * 2; - height: $height + $padding * 2; - padding: $padding; - font-size: math.max($width, $height); - } - - svg, img, forge-icon { - @include mdc-feature-targeting.targets($feat-structure) { - width: $width; - height: $height; - } - } -} - -/// -/// Sets the font color and the ripple color to the provided color value. -/// @param {Color} $color - The desired font and ripple color. -/// -@mixin ink-color($color, $query: mdc-feature-targeting.all()) { - @include ink-color_($color, $query: $query); - @include mdc-ripple-theme.states($color, $query: $query); -} - -/// -/// Flips icon only in RTL context. -/// -@mixin flip-icon-in-rtl($query: mdc-feature-targeting.all()) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - .forge-button__icon { - @include mdc-rtl.rtl { - @include mdc-feature-targeting.targets($feat-structure) { - /* @noflip */ - transform: rotate(180deg); - } - } - } -} - -/// -/// Sets the font color to the provided color value for a disabled icon button. -/// @param {Color} $color - The desired font color. -/// -@mixin disabled-ink-color($color, $query: mdc-feature-targeting.all()) { - @include if-disabled_ { - @include ink-color_($color, $query: $query); - } -} - -/// -/// Includes ad-hoc high contrast mode support. -/// -@mixin high-contrast-mode-shim($query: mdc-feature-targeting.all()) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - @include mdc-feature-targeting.targets($feat-structure) { - // TODO(b/175806874): Use the DOM border mixin after the ripple is moved - // away from :before to a dedicated element. - outline: solid 3px transparent; - - &:focus { - outline: double 5px transparent; - } - } -} - -@mixin base_($query: mdc-feature-targeting.all()) { - $feat-structure: mdc-feature-targeting.create-target($query, structure); - - @include mdc-feature-targeting.targets($feat-structure) { - display: inline-flex; - justify-content: center; - align-items: center; - position: relative; - box-sizing: border-box; - border: none; - outline: none; - background-color: transparent; - fill: currentColor; - color: inherit; - font-size: variables.$icon-size; - text-decoration: none; - cursor: pointer; - user-select: none; - } - - svg, img, forge-icon { - @include mdc-feature-targeting.targets($feat-structure) { - width: variables.$icon-size; - height: variables.$icon-size; - } - } - - @include disabled-ink-color(text-disabled-on-light, $query: $query); - - @include if-disabled_ { - @include mdc-feature-targeting.targets($feat-structure) { - cursor: default; - pointer-events: none; - } - } -} - -/// -/// Sets the font color to the provided color value. This can be wrapped in -/// a state qualifier such as `mdc-icon-button-if-disabled_`. -/// @access private -/// -@mixin ink-color_($color, $query: mdc-feature-targeting.all()) { - $feat-color: mdc-feature-targeting.create-target($query, color); - - @include mdc-feature-targeting.targets($feat-color) { - @include mdc-theme.property(color, $color); - } -} - -/// -/// Helps style the icon button in its disabled state. -/// @access private -/// -@mixin if-disabled_ { - &:disabled { - @content; - } -} diff --git a/src/lib/icon-button/_token-utils.scss b/src/lib/icon-button/_token-utils.scss new file mode 100644 index 000000000..68f9f01df --- /dev/null +++ b/src/lib/icon-button/_token-utils.scss @@ -0,0 +1,25 @@ +@use '../core/styles/tokens/icon-button/tokens'; +@use '../core/styles/tokens/token-utils'; + +$_module: icon-button; +$_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/icon-button/_variables.scss b/src/lib/icon-button/_variables.scss deleted file mode 100644 index d50cfc5c6..000000000 --- a/src/lib/icon-button/_variables.scss +++ /dev/null @@ -1,37 +0,0 @@ -// -// Copyright 2018 Google Inc. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -// - -@use '@material/density/variables'; - -$icon-size: 24px !default; - -$size: 48px !default; -$minimum-height: 24px !default; -$maximum-height: $size !default; -$density-scale: variables.$default-scale !default; -$density-config: ( - size: ( - default: $size, - maximum: $maximum-height, - minimum: $minimum-height, - ), -) !default; diff --git a/src/lib/icon-button/build.json b/src/lib/icon-button/build.json index 97cb49a08..d6e9c5322 100644 --- a/src/lib/icon-button/build.json +++ b/src/lib/icon-button/build.json @@ -1,7 +1,4 @@ { "$schema": "../../../node_modules/@tylertech/forge-cli/config/build-schema.json", - "extends": "../build.json", - "stylesheets": [ - "./forge-icon-button.scss" - ] + "extends": "../build.json" } diff --git a/src/lib/icon-button/forge-icon-button.scss b/src/lib/icon-button/forge-icon-button.scss deleted file mode 100644 index bb795e9bf..000000000 --- a/src/lib/icon-button/forge-icon-button.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use './mixins'; - -@include mixins.core-styles; diff --git a/src/lib/icon-button/icon-button-adapter.ts b/src/lib/icon-button/icon-button-adapter.ts new file mode 100644 index 000000000..3b5d90d3e --- /dev/null +++ b/src/lib/icon-button/icon-button-adapter.ts @@ -0,0 +1,10 @@ +import { BaseButtonAdapter, IBaseButtonAdapter } from '../button/base/base-button-adapter'; +import { IIconButtonComponent } from './icon-button'; + +export interface IIconButtonAdapter extends IBaseButtonAdapter {} + +export class IconButtonAdapter extends BaseButtonAdapter implements IIconButtonAdapter { + constructor(component: IIconButtonComponent) { + super(component); + } +} diff --git a/src/lib/icon-button/icon-button-component-delegate.ts b/src/lib/icon-button/icon-button-component-delegate.ts index 9b1ff53b5..087652e89 100644 --- a/src/lib/icon-button/icon-button-component-delegate.ts +++ b/src/lib/icon-button/icon-button-component-delegate.ts @@ -19,7 +19,6 @@ export interface IIconButtonComponentDelegateOptions extends IBaseComponentDeleg export interface IIconButtonComponentDelegateConfig extends IBaseComponentDelegateConfig {} export class IconButtonComponentDelegate extends BaseComponentDelegate { - private _buttonElement: HTMLButtonElement; private _iconElement?: IIconComponent; constructor(config?: IIconButtonComponentDelegateConfig) { @@ -28,9 +27,6 @@ export class IconButtonComponentDelegate extends BaseComponentDelegate void): void { - this._buttonElement.addEventListener('click', listener); + this._element.addEventListener('click', listener); } public onFocus(listener: (evt: Event) => void): void { - this._buttonElement.addEventListener('focus', evt => listener(evt)); + this._element.addEventListener('focus', evt => listener(evt)); } public onBlur(listener: (evt: Event) => void): void { - this._buttonElement.addEventListener('blur', evt => listener(evt)); + this._element.addEventListener('blur', evt => listener(evt)); } } diff --git a/src/lib/icon-button/icon-button-constants.ts b/src/lib/icon-button/icon-button-constants.ts index 8e2aa7c69..bb1c8be73 100644 --- a/src/lib/icon-button/icon-button-constants.ts +++ b/src/lib/icon-button/icon-button-constants.ts @@ -1,44 +1,41 @@ -import { COMPONENT_NAME_PREFIX } from '../constants'; +import { COMPONENT_NAME_PREFIX, Theme } from '../constants'; const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}icon-button`; -const attributes = { +const observedAttributes = { TOGGLE: 'toggle', - IS_ON: 'is-on', - ICON_ON: 'forge-icon-button-on', - DENSE: 'dense', - DENSITY_LEVEL: 'density-level' + ON: 'on', + VARIANT: 'variant', + THEME: 'theme', + SHAPE: 'shape', + DENSITY: 'density' }; -const selectors = { - BUTTON: 'button, a', - ICON: 'i, span, svg, img, forge-icon' +const attributes = { + ...observedAttributes, + ARIA_PRESSED: 'aria-pressed' }; -const classes = { - BUTTON: 'forge-icon-button', - BUTTON_ON: 'forge-icon-button--on', - BUTTON_DENSE: 'forge-icon-button--dense', - ICON: 'forge-icon-button__icon', - ICON_ON: 'forge-icon-button__icon--on', - DENSITY: [ - 'forge-icon-button--dense-1', - 'forge-icon-button--dense-2', - 'forge-icon-button--dense-3', - 'forge-icon-button--dense-4', - 'forge-icon-button--dense-5', - 'forge-icon-button--dense-6' - ] +const events = { + TOGGLE: `${elementName}-toggle` }; -const events = { - CHANGE: `${elementName}-change` +const defaults = { + DEFAULT_VARIANT: 'icon' as IconButtonVariant, + DEFAULT_THEME: 'primary' as IconButtonTheme, + DEFAULT_SHAPE: 'circular' as IconButtonShape, + DEFAULT_DENSITY: 'large' as IconButtonDensity }; export const ICON_BUTTON_CONSTANTS = { elementName, + observedAttributes, attributes, - selectors, - classes, - events + events, + defaults }; + +export type IconButtonVariant = 'icon' | 'outlined' | 'tonal' | 'filled' | 'raised'; +export type IconButtonTheme = Theme; +export type IconButtonShape = 'circular' | 'squared'; +export type IconButtonDensity = 'small' | 'medium' | 'large'; diff --git a/src/lib/icon-button/icon-button-foundation.ts b/src/lib/icon-button/icon-button-foundation.ts new file mode 100644 index 000000000..cb36bdedd --- /dev/null +++ b/src/lib/icon-button/icon-button-foundation.ts @@ -0,0 +1,141 @@ +import { BaseButtonFoundation, IBaseButtonFoundation } from '../button/base/base-button-foundation'; +import { IIconButtonAdapter } from './icon-button-adapter'; +import { IconButtonDensity, IconButtonShape, IconButtonTheme, IconButtonVariant, ICON_BUTTON_CONSTANTS } from './icon-button-constants'; + +export interface IIconButtonFoundation extends IBaseButtonFoundation { + toggle: boolean; + on: boolean; + variant: IconButtonVariant; + theme: IconButtonTheme; + shape: IconButtonShape; + density: IconButtonDensity; +} + +export class IconButtonFoundation extends BaseButtonFoundation implements IIconButtonFoundation { + private _toggle = false; + private _on = false; + private _variant: IconButtonVariant = ICON_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT; + private _theme: IconButtonTheme = ICON_BUTTON_CONSTANTS.defaults.DEFAULT_THEME; + private _shape: IconButtonShape = ICON_BUTTON_CONSTANTS.defaults.DEFAULT_SHAPE; + private _density: IconButtonDensity = ICON_BUTTON_CONSTANTS.defaults.DEFAULT_DENSITY; + + constructor(adapter: IIconButtonAdapter) { + super(adapter); + } + + protected override async _onClick(evt: MouseEvent): Promise { + if (this._toggle) { + this._onToggle(); + } + super._onClick(evt); + } + + private _onToggle(): void { + // Update internal state first so listeners can access the new state + const originalOn = this._on; + this._on = !this._on; + + const cancelled = !this._adapter.emitHostEvent(ICON_BUTTON_CONSTANTS.events.TOGGLE, this.on, true, true); + this._on = originalOn; + + if (cancelled) { + return; + } + + this.on = !originalOn; + } + + public get toggle(): boolean { + return this._toggle; + } + public set toggle(value: boolean) { + value = !!value; + if (this._toggle !== value) { + this._toggle = value; + this._adapter.toggleHostAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED, this._toggle, `${this._on}`); + this._adapter.toggleHostAttribute(ICON_BUTTON_CONSTANTS.attributes.TOGGLE, this._toggle); + } + } + + public get on(): boolean { + return this._on; + } + public set on(value: boolean) { + value = !!value; + if (this._on !== value) { + this._on = value; + + if (this._toggle) { + this._adapter.setHostAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED, `${this._on}`); + } else { + this._adapter.removeHostAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED); + } + + this._adapter.toggleHostAttribute(ICON_BUTTON_CONSTANTS.attributes.ON, this._on); + } + } + + public get variant(): IconButtonVariant { + return this._variant; + } + public set variant(value: IconButtonVariant) { + value = value ?? ICON_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT; + if (this._variant !== value) { + this._variant = value; + + if (this._variant !== ICON_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT) { + this._adapter.setHostAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT, this._variant); + } else { + this._adapter.removeHostAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT); + } + } + } + + public get theme(): IconButtonTheme { + return this._theme; + } + public set theme(value: IconButtonTheme) { + value = value ?? ICON_BUTTON_CONSTANTS.defaults.DEFAULT_THEME; + if (this._theme !== value) { + this._theme = value; + + if (this._theme !== ICON_BUTTON_CONSTANTS.defaults.DEFAULT_THEME) { + this._adapter.setHostAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME, this._theme); + } else { + this._adapter.removeHostAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME); + } + } + } + + public get shape(): IconButtonShape { + return this._shape; + } + public set shape(value: IconButtonShape) { + value = value ?? ICON_BUTTON_CONSTANTS.defaults.DEFAULT_SHAPE; + if (this._shape !== value) { + this._shape = value; + + if (this._shape !== ICON_BUTTON_CONSTANTS.defaults.DEFAULT_SHAPE) { + this._adapter.setHostAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE, this._shape); + } else { + this._adapter.removeHostAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE); + } + } + } + + public get density(): IconButtonDensity { + return this._density; + } + public set density(value: IconButtonDensity) { + value = value ?? ICON_BUTTON_CONSTANTS.defaults.DEFAULT_DENSITY; + if (this._density !== value) { + this._density = value; + + if (this._density !== ICON_BUTTON_CONSTANTS.defaults.DEFAULT_DENSITY) { + this._adapter.setHostAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY, this._density); + } else { + this._adapter.removeHostAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY); + } + } + } +} diff --git a/src/lib/icon-button/icon-button.html b/src/lib/icon-button/icon-button.html new file mode 100644 index 000000000..daf389d12 --- /dev/null +++ b/src/lib/icon-button/icon-button.html @@ -0,0 +1,10 @@ + diff --git a/src/lib/icon-button/icon-button.scss b/src/lib/icon-button/icon-button.scss new file mode 100644 index 000000000..b70f2f6c5 --- /dev/null +++ b/src/lib/icon-button/icon-button.scss @@ -0,0 +1,310 @@ +@use './configuration'; +@use './core'; +@use '../core/styles/theme'; +@use '../state-layer' as state-layer; +@use '../focus-indicator' as focus-indicator; +@use './token-utils' as *; + +// +// Host +// + +:host { + @include configuration.host-configuration; +} + +:host { + @include core.host; +} + +:host([hidden]) { + display: none; +} + +// +// Base +// + +.forge-icon-button { + @include configuration.configuration; +} + +.forge-icon-button { + @include core.base; + + ::slotted(:is([slot=start],[slot=end])) { + @include core.slotted-start-end; + } +} + +a { + @include core.anchor-base; +} + +// +// Focus indicator +// + +forge-focus-indicator { + @include focus-indicator.provide-theme(( + color: #{token(focus-indicator-color)}, + shape-start-start: #{token(shape-start-start)}, + shape-start-end: #{token(shape-start-end)}, + shape-end-start: #{token(shape-end-start)}, + shape-end-end: #{token(shape-end-end)} + )); +} + +:host(:is([variant=icon],:not([variant]))) { + @include focus-indicator.provide-theme(( + outward-offset: 0px // Requires unit + )); +} + +// +// State layer +// + +forge-state-layer { + @include state-layer.provide-theme(( + color: #{token(icon-color)} + )); +} + +// +// Popover icon +// + +:host([popover-icon]) { + .forge-icon-button { + @include override(padding, popover-icon-padding); + } +} + +// +// Variants +// + +:host([variant=outlined]) { + .forge-icon-button { + @include core.outlined; + } +} + +:host([variant=tonal]) { + .forge-icon-button { + @include core.tonal; + } +} + +:host(:is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include core.filled; + } +} + +:host([variant=raised]) { + .forge-icon-button { + @include core.raised; + } +} + +// +// Toggle +// + +:host(:is(:not([toggle]),[toggle]:not([on]))) { + slot[name=on] { + display: none; + } +} + +:host([toggle][on]) { + slot:not([name]) { + display: none; + } +} + +:host([toggle][on]:is(:not([variant]),[variant=icon])) { + .forge-icon-button { + @include core.toggle-on-icon; + } +} + +:host([toggle]:not([on])[variant=outlined]) { + .forge-icon-button { + @include core.toggle-outlined; + } +} + +:host([toggle][on][variant=outlined]) { + .forge-icon-button { + @include core.toggle-on-outlined; + } +} + +:host([toggle]:not([on])[variant=tonal]) { + .forge-icon-button { + @include core.toggle-tonal; + } +} + +:host([toggle][on][variant=tonal]) { + .forge-icon-button { + @include core.toggle-on-tonal; + } +} + +:host([toggle]:not([on]):is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include core.toggle-filled; + } +} + +:host([toggle][on]:is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include core.toggle-on-filled; + } +} + +// +// Density +// + +:host(:is([dense],[density=small])) { + .forge-icon-button { + @include core.density-small; + } + + ::slotted(*) { + @include core.density-small-slotted; + } +} + +:host([density=medium]) { + .forge-icon-button { + @include core.density-medium; + } +} + +// +// Shape +// + +:host([shape=squared]) { + .forge-icon-button { + @include override(shape, shape-squared); + } +} + +// +// Disabled +// + +:host([disabled]) { + @include core.host-disabled; + + .forge-icon-button { + @include core.disabled; + } +} + +:host([disabled][variant=raised]) { + .forge-icon-button { + @include core.raised-disabled; + } +} + +// +// Theme +// + +@mixin theme($theme) { + // Non-toggle + icon (default) & outlined variants + :host(:not([toggle])[theme=#{$theme}]:is(:not([variant]),[variant=icon],[variant=outlined])) { + .forge-icon-button { + @include override(icon-color, theme.variable($theme), value); + } + } + + // Toggle + icon (default) + :host([toggle][theme=#{$theme}]:is(:not([variant]),[variant=icon])) { + .forge-icon-button { + @include override(toggle-on-icon-color, theme.variable($theme), value); + } + } + + // Toggle + outlined + :host([toggle][theme=#{$theme}][variant=outlined]) { + .forge-icon-button { + @include override(icon-color, theme.variable($theme), value); + @include override(outlined-toggle-on-background-color, theme.variable(#{$theme}-container-low), value); + @include override(outlined-toggle-on-icon-color, theme.variable($theme), value); + } + } + + // Primary theme is the default for filled and tonal variants + @if ($theme != 'primary') { + // Non-toggle + tonal variant + :host(:not([toggle])[theme=#{$theme}][variant=tonal]) { + .forge-icon-button { + @include override(icon-color, theme.variable(on-#{$theme}-container), value); + @include override(background-color, theme.variable(#{$theme}-container), value); + } + } + + // Toggle + tonal variant + :host([toggle]:not([on])[theme=#{$theme}][variant=tonal]) { + .forge-icon-button { + @include override(background-color, theme.variable(#{$theme}-container-low), value); + } + } + + // Toggle (on) + tonal variant + :host([toggle][theme=#{$theme}][variant=tonal]) { + .forge-icon-button { + @include override(tonal-toggle-on-background-color, theme.variable(#{$theme}-container), value); + @include override(tonal-toggle-on-icon-color, theme.variable(on-#{$theme}-container), value); + } + } + + // Non-toggle + filled & raised variants + :host(:not([toggle])[theme=#{$theme}]:is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include override(icon-color, theme.variable(on-#{$theme}), value); + @include override(background-color, theme.variable($theme), value); + } + } + + // Toggle + filled & raised variants + :host([toggle]:not([on])[theme=#{$theme}]:is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include override(icon-color, theme.variable($theme), value); + @include override(background-color, theme.variable(#{$theme}-container-low), value); + } + } + + // Toggle (on) + filled & raised variants + :host([toggle][theme=#{$theme}]:is([variant=filled],[variant=raised])) { + .forge-icon-button { + @include override(filled-toggle-on-background-color, theme.variable($theme), value); + @include override(filled-toggle-on-icon-color, theme.variable(on-#{$theme}), value); + } + } + } + + // Focus indicator + :host([theme=#{$theme}]) { + .forge-icon-button { + @include override(focus-indicator-color, theme.variable($theme), value); + } + } +} + +@include theme(primary); +@include theme(secondary); +@include theme(tertiary); +@include theme(success); +@include theme(error); +@include theme(warning); +@include theme(info); diff --git a/src/lib/icon-button/icon-button.test.ts b/src/lib/icon-button/icon-button.test.ts new file mode 100644 index 000000000..8df07f075 --- /dev/null +++ b/src/lib/icon-button/icon-button.test.ts @@ -0,0 +1,362 @@ +import { expect } from '@esm-bundle/chai'; +import { spy } from 'sinon'; +import { sendMouse } from '@web/test-runner-commands'; +import { elementUpdated, fixture, html } from '@open-wc/testing'; +import { tylIconMoreVert } from '@tylertech/tyler-icons/standard'; +import { IconRegistry } from '../icon/icon-registry'; +import { ICON_BUTTON_CONSTANTS } from './icon-button-constants'; +import { IconButtonComponent, IIconButtonComponent } from './icon-button'; + +import './icon-button'; +import { IconButtonComponentDelegate } from './icon-button-component-delegate'; +import { ITooltipComponent } from '../tooltip'; +import { ICON_CLASS_NAME } from '../constants'; + +const DEFAULT_ICON = ''; + +describe('Icon Button', () => { + before(() => { + IconRegistry.define(tylIconMoreVert); + }); + + it('should initialize', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + expect(el.shadowRoot).not.to.be.null; + }); + + it('should not be toggle by default', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.toggle).to.be.false; + expect(el.on).to.be.false; + }); + + it('should have default variant', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.variant).to.equal('icon'); + }); + + it('should set variant', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.variant).to.equal('outlined'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT)).to.equal('outlined'); + }); + + it('should set variant dynamically', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.variant = 'tonal'; + + expect(el.variant).to.equal('tonal'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT)).to.equal('tonal'); + }); + + it('should remove reset variant when variant attribute is removed', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT); + + expect(el.variant).to.equal('icon'); + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.VARIANT)).to.be.false; + }); + + it('should have default theme', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.theme).to.equal('primary'); + }); + + it('should set theme', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.theme).to.equal('error'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME)).to.equal('error'); + }); + + it('should set theme dynamically', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.theme = 'secondary'; + + expect(el.theme).to.equal('secondary'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME)).to.equal('secondary'); + }); + + it('should remove reset theme when theme attribute is removed', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME); + + expect(el.theme).to.equal('primary'); + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.THEME)).to.be.false; + }); + + it('should have default shape', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.shape).to.equal('circular'); + }); + + it('should set shape', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.shape).to.equal('squared'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE)).to.equal('squared'); + }); + + it('should set shape dynamically', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.shape = 'squared'; + + expect(el.shape).to.equal('squared'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE)).to.equal('squared'); + }); + + it('should remove reset shape when shape attribute is removed', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE); + + expect(el.shape).to.equal('circular'); + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.SHAPE)).to.be.false; + }); + + it('should have default density', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.density).to.equal('large'); + }); + + it('should set density', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.density).to.equal('small'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY)).to.equal('small'); + }); + + it('should set density dynamically', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.density = 'small'; + + expect(el.density).to.equal('small'); + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY)).to.equal('small'); + }); + + it('should remove reset density when density attribute is removed', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY); + + expect(el.density).to.equal('large'); + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY)).to.be.false; + }); + + it('should be accessible with a aria-label', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + await expect(el).to.be.accessible(); + }); + + it('should be accessible with a aria-labelledby', async () => { + const el = await fixture(html` +
+ ${DEFAULT_ICON} + +
+ `); + const iconButton = el.querySelector('forge-icon-button') as IIconButtonComponent; + await expect(iconButton).to.be.accessible(); + }); + + it('should set toggle', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.toggle).to.be.true; + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.TOGGLE)).to.be.true; + }); + + it('should set toggle dynamically', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.toggle = true; + + expect(el.toggle).to.be.true; + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.TOGGLE)).to.be.true; + }); + + it('should remove reset toggle when toggle attribute is removed', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.TOGGLE); + + expect(el.toggle).to.be.false; + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.TOGGLE)).to.be.false; + }); + + it('should not be on by default', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.on).to.be.false; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('false'); + }); + + it('should toggle on click', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.click(); + + expect(el.on).to.be.true; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('true'); + }); + + it('should toggle on click when on is set', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.click(); + + expect(el.on).to.be.false; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('false'); + }); + + it('should not toggle on click when disabled', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.click(); + + expect(el.on).to.be.false; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('false'); + }); + + it('should not toggle to on when toggle event cancelled', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + const clickSpy = spy((evt: CustomEvent) => evt.preventDefault()); + + expect(el.on).to.be.false; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('false'); + + el.addEventListener(ICON_BUTTON_CONSTANTS.events.TOGGLE, clickSpy); + el.click(); + await elementUpdated(el); + + expect(clickSpy).to.be.calledOnce; + expect(el.on).to.be.false; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('false'); + }); + + it('should not toggle to off when click event cancelled', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + const clickSpy = spy((evt: CustomEvent) => evt.preventDefault()); + + expect(el.on).to.be.true; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('true'); + + el.addEventListener(ICON_BUTTON_CONSTANTS.events.TOGGLE, clickSpy); + el.click(); + await elementUpdated(el); + + expect(clickSpy).to.be.calledOnce; + expect(el.on).to.be.true; + expect(el.getAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.equal('true'); + }); + + it('should not enable toggle if on is set while toggle is off', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + expect(el.toggle).to.be.false; + expect(el.on).to.be.true; + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.be.false; + }); + + it('should remove aria-pressed if on is set while toggle is off', async () => { + const el = await fixture(html`${DEFAULT_ICON}`); + + el.on = false; + + expect(el.toggle).to.be.false; + expect(el.on).to.be.false; + expect(el.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.ARIA_PRESSED)).to.be.false; + }); + + describe('IconButtonComponentDelegate', () => { + it('should create icon button via delegate', async () => { + const delegate = new IconButtonComponentDelegate({ options: { iconName: 'more_vert', iconExternal: false, iconExternalType: 'standard', iconClass: 'my-custom-class' }}); + + expect(delegate.element).to.be.instanceOf(IconButtonComponent); + expect(delegate.iconElement?.name).to.equal('more_vert'); + expect(delegate.iconElement?.external).to.be.false; + expect(delegate.iconElement?.externalType).to.equal('standard'); + expect(delegate.iconElement?.classList.contains('my-custom-class')).to.be.true; + }); + + it('should set font-based icon', async () => { + const delegate = new IconButtonComponentDelegate({ options: { iconName: 'more_vert', iconType: 'font' }}); + + expect(delegate.element.innerText).to.equal('more_vert'); + expect(delegate.element.classList.contains(ICON_CLASS_NAME)).to.be.true; + }); + + it('should set tooltip via delegate', async () => { + const delegate = new IconButtonComponentDelegate({ options: { tooltip: 'Test tooltip', tooltipPosition: 'bottom' }}); + + const tooltipEl = delegate.element.querySelector('forge-tooltip') as ITooltipComponent; + expect(tooltipEl.innerText).to.equal('Test tooltip'); + }); + + it('should set disabled via delegate', async () => { + const delegate = new IconButtonComponentDelegate(); + + delegate.disabled = true; + + expect(delegate.disabled).to.be.true; + }); + + it('should call click handler via delegate', async () => { + const delegate = new IconButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const clickSpy = spy(); + + delegate.onClick(clickSpy); + await clickElement(delegate.element); + delegate.element.remove(); + + expect(clickSpy).to.be.calledOnce; + }); + + it('should call focus handler via delegate', async () => { + const delegate = new IconButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const focusSpy = spy(); + + delegate.onFocus(focusSpy); + delegate.element.focus(); + await clickElement(delegate.element); + delegate.element.remove(); + + expect(focusSpy).to.be.calledOnce; + }); + + it('should call blur handler via delegate', async () => { + const delegate = new IconButtonComponentDelegate(); + document.body.appendChild(delegate.element); + const blurSpy = spy(); + + delegate.onBlur(blurSpy); + delegate.element.focus(); + await clickElement(document.body); + delegate.element.remove(); + + expect(blurSpy).to.be.calledOnce; + }); + }); + + 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/icon-button/icon-button.ts b/src/lib/icon-button/icon-button.ts index 7b803ccea..2c34e8fc1 100644 --- a/src/lib/icon-button/icon-button.ts +++ b/src/lib/icon-button/icon-button.ts @@ -1,15 +1,24 @@ -import { coerceBoolean, coerceNumber, CustomElement, emitEvent, ensureChild, toggleClass } from '@tylertech/forge-core'; -import { BaseComponent, IBaseComponent } from '../core/base/base-component'; -import { ForgeRipple } from '../ripple'; -import { createUserInteractionListener } from '../core/utils'; -import { ICON_BUTTON_CONSTANTS } from './icon-button-constants'; - -export interface IIconButtonComponent extends IBaseComponent { +import { attachShadowTemplate, coerceBoolean, CustomElement, FoundationProperty, toggleAttribute } from '@tylertech/forge-core'; +import { tylIconArrowDropDown } from '@tylertech/tyler-icons/standard'; +import { IconComponent, IconRegistry } from '../icon'; +import { BaseButton, IBaseButton } from '../button/base/base-button'; +import { BASE_BUTTON_CONSTANTS } from '../button/base/base-button-constants'; +import { FocusIndicatorComponent } from '../focus-indicator'; +import { StateLayerComponent } from '../state-layer'; +import { IconButtonDensity, IconButtonShape, IconButtonTheme, IconButtonVariant, ICON_BUTTON_CONSTANTS } from './icon-button-constants'; +import { IconButtonFoundation } from './icon-button-foundation'; +import { IconButtonAdapter } from './icon-button-adapter'; + +import template from './icon-button.html'; +import styles from './icon-button.scss'; + +export interface IIconButtonComponent extends IBaseButton { toggle: boolean; - isOn: boolean; - dense: boolean; - densityLevel: number; - layout(): void; + on: boolean; + variant: IconButtonVariant; + theme: IconButtonTheme; + shape: IconButtonShape; + density: IconButtonDensity; } declare global { @@ -18,269 +27,128 @@ declare global { } interface HTMLElementEventMap { - 'forge-icon-button-change': CustomEvent; + 'forge-icon-button-toggle': CustomEvent; } } /** - * The custom element class behind the `` element. - * * @tag forge-icon-button + * + * @summary Icons buttons are used to trigger an action or event. + * + * @property {boolean} toggle - Whether or not the icon button can be toggled. + * @property {boolean} on - Whether or not the button is on. Only applies when `toggle` is `true`. + * @property {IconButtonVariant} variant - The variant of the button. Valid values are `text`, `outlined`, `filled`, and `raised`. + * @property {IconButtonTheme} theme - The theme of the button. Valid values are `primary`, `secondary`, `tertiary`, `success`, `error`, `warning`, `info`. + * @property {string} shape - The shape of the button. Valid values are `circular` and `squared`. + * @property {IconButtonDensity} density - The density of the button. Valid values are `small`, `medium`, and `large`. + * @property {string} type - The type of button. Defaults to `button`. Valid values are `button`, `submit`, and `reset`. + * @property {boolean} disabled - Whether or not the button is disabled. + * @property {boolean} popoverIcon - Whether or not the button shows a built-in popover icon. + * @property {string} name - The name of the button. + * @property {string} value - The form value of the button. + * @property {boolean} dense - Whether or not the button is dense. + * @property {boolean} anchor - Whether or not the button is an `
` element. + * @property {string} href - The href of the anchor. + * @property {string} target - The target of the anchor. + * @property {string} download - The download of the anchor. + * @property {string} rel - The rel of the anchor. + * @property {HTMLFormElement | null} form - The form reference of the button if within a `
` element. + * + * @attribute {boolean} toggle - Whether or not the icon button can be toggled. + * @attribute {boolean} on - Whether or not the button is on. Only applies when `toggle` is `true`. + * @attribute {IconButtonVariant} variant - The variant of the button. Valid values are `text`, `outlined`, `filled`, and `raised`. + * @attribute {IconButtonTheme} theme - The theme of the button. Valid values are `primary`, `secondary`, `tertiary`, `success`, `error`, `warning`, `info`. + * @attribute {string} shape - The shape of the button. Valid values are `circular` and `squared`. + * @attribute {IconButtonDensity} density - The density of the button. Valid values are `small`, `medium`, and `large`. + * @attribute {string} type - The type of button. Defaults to `button`. Valid values are `button`, `submit`, and `reset`. + * @attribute {boolean} disabled - Whether or not the button is disabled. + * @attribute {boolean} popover-icon - Whether or not the button shows a built-in popover icon. + * @attribute {string} name - The name of the button. + * @attribute {string} value - The form value of the button. + * @attribute {boolean} dense - Whether or not the button is dense. + * @attribute {boolean} anchor - Whether or not the button is an `` element. + * @attribute {string} href - The href of the anchor. + * @attribute {string} target - The target of the anchor. + * @attribute {string} download - The download of the anchor. + * @attribute {string} rel - The rel of the anchor. + * + * @event {Event} click - Fires when the button is clicked. + * @event {Event} forge-icon-button-toggle - Fires when the icon button is toggled. + * + * @csspart root - The root container element. + * @csspart focus-indicator - The focus-indicator indicator element. + * @csspart state-layer - The state-layer surface element. + * + * @slot - This is a default/unnamed slot for the icon. + * @slot on - The icon to show when in `toggle` mode when toggled "on". + * @slot start - Elements to logically render before the icon. + * @slot end - Elements to logically render after the icon. */ @CustomElement({ - name: ICON_BUTTON_CONSTANTS.elementName + name: ICON_BUTTON_CONSTANTS.elementName, + dependencies: [ + FocusIndicatorComponent, + StateLayerComponent, + IconComponent + ] }) -export class IconButtonComponent extends BaseComponent implements IIconButtonComponent { +export class IconButtonComponent extends BaseButton implements IIconButtonComponent { public static get observedAttributes(): string[] { return [ - ICON_BUTTON_CONSTANTS.attributes.IS_ON, - ICON_BUTTON_CONSTANTS.attributes.DENSE, - ICON_BUTTON_CONSTANTS.attributes.DENSITY_LEVEL, - ICON_BUTTON_CONSTANTS.attributes.TOGGLE + ...Object.values(BASE_BUTTON_CONSTANTS.observedAttributes), + ...Object.values(ICON_BUTTON_CONSTANTS.observedAttributes) ]; } - private _rippleInstance: ForgeRipple; - private _buttonElement: HTMLButtonElement; - private _toggle = false; - private _isOn = false; - private _dense = false; - private _densityLevel = 5; - private _toggleHandler: (event: Event) => void; - private _destroyUserInteractionListener: (() => void) | undefined; + protected readonly _foundation: IconButtonFoundation; constructor() { super(); + IconRegistry.define(tylIconArrowDropDown); + attachShadowTemplate(this, template, styles); + this._foundation = new IconButtonFoundation(new IconButtonAdapter(this)); } - public connectedCallback(): void { - if (this.querySelector(ICON_BUTTON_CONSTANTS.selectors.BUTTON)) { - this._initialize(); - } else { - ensureChild(this, ICON_BUTTON_CONSTANTS.selectors.BUTTON).then(() => this._initialize()); - } - } - - public disconnectedCallback(): void { - if (typeof this._destroyUserInteractionListener === 'function') { - this._destroyUserInteractionListener(); - this._destroyUserInteractionListener = undefined; - } - - if (this._rippleInstance) { - this._rippleInstance.destroy(); - } - } - - public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { + public override attributeChangedCallback(name: string, oldValue: string, newValue: string): void { switch (name) { - case ICON_BUTTON_CONSTANTS.attributes.IS_ON: - this.isOn = coerceBoolean(newValue); + case ICON_BUTTON_CONSTANTS.attributes.TOGGLE: + this.toggle = coerceBoolean(newValue); break; - case ICON_BUTTON_CONSTANTS.attributes.DENSE: - this.dense = coerceBoolean(newValue); + case ICON_BUTTON_CONSTANTS.attributes.ON: + this.on = coerceBoolean(newValue); break; - case ICON_BUTTON_CONSTANTS.attributes.DENSITY_LEVEL: - this.densityLevel = coerceNumber(newValue); + case ICON_BUTTON_CONSTANTS.attributes.VARIANT: + this.variant = newValue as IconButtonVariant; break; - case ICON_BUTTON_CONSTANTS.attributes.TOGGLE: - this.toggle = coerceBoolean(newValue); + case ICON_BUTTON_CONSTANTS.attributes.THEME: + this.theme = newValue as IconButtonTheme; + break; + case ICON_BUTTON_CONSTANTS.attributes.SHAPE: + this.shape = newValue as IconButtonShape; + break; + case ICON_BUTTON_CONSTANTS.attributes.DENSITY: + this.density = newValue as IconButtonDensity; break; } + super.attributeChangedCallback(name, oldValue, newValue); } - /** Gets/sets whether the button is togglable. */ - public get toggle(): boolean { - return this._toggle; - } - public set toggle(value: boolean) { - this._toggle = value; - - if (this._toggle) { - this._initializeToggle(); - } else { - this._destroyToggle(); - } - } - - /** Gets/sets the toggled state of the icon button. Only applies when `toggle = true`. */ - public get isOn(): boolean { - return this._isOn; - } - public set isOn(value: boolean) { - if (this._isOn !== value) { - this._isOn = value; - this._applyToggle(); - } - } - - /** Gets/sets whether the icon button is dense. */ - public get dense(): boolean { - return this._dense; - } - public set dense(value: boolean) { - if (this._dense !== value) { - this._dense = value; - this._applyDensity(); - } - } - - /** Controls the density level. 1 (least dense) to 6 (most dense). */ - public get densityLevel(): number { - return this._densityLevel; - } - public set densityLevel(value: number) { - if (this._densityLevel !== value) { - this._densityLevel = value; - - if (this._densityLevel <= 0) { - this._densityLevel = 1; - } else if (this._densityLevel > 6) { - this._densityLevel = 6; - } else if (typeof this._densityLevel !== 'number') { - this._densityLevel = 5; - } - - this._applyDensity(); - } - } - - private _initialize(): void { - this._buttonElement = this.querySelector(ICON_BUTTON_CONSTANTS.selectors.BUTTON) as HTMLButtonElement; - if (!this._buttonElement) { - return; - } - - this._buttonElement.classList.add(ICON_BUTTON_CONSTANTS.classes.BUTTON); - this._applyToggle(); - this._applyDensity(); - this._toggleHandler = () => { - this._toggleValue(); - emitEvent(this, ICON_BUTTON_CONSTANTS.events.CHANGE, this._isOn, true); - }; - - if (this._toggle) { - this._initializeToggle(); - } - - // We wait to initialize the ripple instance until the user interacts with the component to avoid unnecessary performance overhead - this._deferRippleInitialization(); - } - - private async _deferRippleInitialization(): Promise { - const { userInteraction, destroy } = createUserInteractionListener(this._buttonElement); - this._destroyUserInteractionListener = destroy; - const { type } = await userInteraction; - this._destroyUserInteractionListener = undefined; - if (!this._rippleInstance) { - this._rippleInstance = this._createRipple(); - if (type === 'focusin') { - this._rippleInstance.handleFocus(); - } - } - } - - private _createRipple(): ForgeRipple { - if (this._rippleInstance) { - this._rippleInstance.destroy(); - } - const ripple = new ForgeRipple(this._buttonElement); - ripple.unbounded = true; - return ripple; - } - - private _toggleValue(): void { - this._isOn = !this._isOn; - this._applyToggle(); - } - - private _applyToggle(): void { - if (!this._buttonElement) { - return; - } - toggleClass(this._buttonElement, this._isOn, ICON_BUTTON_CONSTANTS.classes.BUTTON_ON); - if (this._toggle) { - this._buttonElement.setAttribute('aria-pressed', `${this._isOn}`); - } - } - - private _applyDensity(): void { - if (!this._buttonElement) { - return; - } - - // Remove all other density classes first - ICON_BUTTON_CONSTANTS.classes.DENSITY.forEach(c => this._buttonElement.classList.remove(c)); - - if (this._dense) { - this.setAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSE, ''); - this._buttonElement.classList.add(ICON_BUTTON_CONSTANTS.classes.BUTTON_DENSE); - - // 5 is the default density level (we apply 5 implicitly in the regular dense class) - // Exclude 5 since its already covered by dense class - if (this._densityLevel < 7 && this._densityLevel > 0 && this.densityLevel !== 5) { - const densityLevelClass = ICON_BUTTON_CONSTANTS.classes.DENSITY[this._densityLevel - 1]; - this._buttonElement.classList.add(densityLevelClass); - this.setAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSITY_LEVEL, this._densityLevel.toString()); - } - } else { - this.removeAttribute(ICON_BUTTON_CONSTANTS.attributes.DENSE); - this._buttonElement.classList.remove(ICON_BUTTON_CONSTANTS.classes.BUTTON_DENSE); - } - - // re-layout the ripple for cases where dense was changed after initial layout - if (this._rippleInstance) { - this._rippleInstance.layout(); - } - } - - private _initializeToggle(): void { - if (!this._buttonElement) { - return; - } - const icons = Array.from(this._buttonElement.querySelectorAll(ICON_BUTTON_CONSTANTS.selectors.ICON)); + @FoundationProperty() + public declare toggle: boolean; - // We require two icon/image elements to be specified for the "on" and "off" states - if (icons.length !== 2) { - console.error('You must specify two icons, one for "on" and one for "off".'); - return; - } + @FoundationProperty() + public declare on: boolean; - // Add the icon class to each icon - icons.forEach(icon => icon.classList.add(ICON_BUTTON_CONSTANTS.classes.ICON)); + @FoundationProperty() + public declare theme: IconButtonTheme; - // If there are no icons that specify the "on" class, then automatically choose the first icon as the "on" icon and add the class, - // alternatively we check for the existence of a `forge-icon-button-on` attribute on any of the icons and use that. - if (!icons.some(icon => icon.classList.contains(ICON_BUTTON_CONSTANTS.classes.ICON_ON))) { - const requestedOnIcon = icons.find(icon => icon.hasAttribute(ICON_BUTTON_CONSTANTS.attributes.ICON_ON)); - if (requestedOnIcon) { - requestedOnIcon.classList.add(ICON_BUTTON_CONSTANTS.classes.ICON_ON); - } else { - icons[0].classList.add(ICON_BUTTON_CONSTANTS.classes.ICON_ON); - } - } + @FoundationProperty() + public declare variant: IconButtonVariant; - this._buttonElement.addEventListener('click', this._toggleHandler); + @FoundationProperty() + public declare shape: IconButtonShape; - // Wait a frame to ensure the value of the `on` property has been set - window.requestAnimationFrame(() => { - if (this._isOn) { - this._buttonElement.classList.add(ICON_BUTTON_CONSTANTS.classes.BUTTON_ON); - this._buttonElement.setAttribute('aria-pressed', `${this._isOn}`); - } - }); - } - - private _destroyToggle(): void { - if (!this._buttonElement) { - return; - } - this._buttonElement.removeEventListener('click', this._toggleHandler); - } - - public layout(): void { - if (this._rippleInstance) { - this._rippleInstance.layout(); - } - } + @FoundationProperty() + public declare density: IconButtonDensity; } diff --git a/src/lib/icon-button/index.scss b/src/lib/icon-button/index.scss new file mode 100644 index 000000000..1ca832e7e --- /dev/null +++ b/src/lib/icon-button/index.scss @@ -0,0 +1,3 @@ +@forward './configuration'; +@forward './core'; +@forward './token-utils' show provide-theme; diff --git a/src/lib/label/_core.scss b/src/lib/label/_core.scss index 6c65575f3..3cac3cb18 100644 --- a/src/lib/label/_core.scss +++ b/src/lib/label/_core.scss @@ -7,5 +7,6 @@ @mixin label { @include typography.style(label); + display: contents; cursor: default; } diff --git a/src/lib/label/label-adapter.ts b/src/lib/label/label-adapter.ts index 54ea31695..3a9ea3e71 100644 --- a/src/lib/label/label-adapter.ts +++ b/src/lib/label/label-adapter.ts @@ -111,6 +111,7 @@ export class LabelAdapter extends BaseAdapter implements ILabel const rootNode = this._component.getRootNode() as Document | ShadowRoot; targetEl = rootNode.querySelector(`#${id}`); } else { + // Used for nested elements within the label component const selector = LABEL_CONSTANTS.labelableChildSelectors.join(','); targetEl = this._component.querySelector(selector); } diff --git a/src/lib/label/label-constants.ts b/src/lib/label/label-constants.ts index 0325fdb09..6f8522536 100644 --- a/src/lib/label/label-constants.ts +++ b/src/lib/label/label-constants.ts @@ -1,5 +1,7 @@ +import { BUTTON_CONSTANTS } from '../button'; import { CHECKBOX_CONSTANTS } from '../checkbox'; import { COMPONENT_NAME_PREFIX } from '../constants'; +import { ICON_BUTTON_CONSTANTS } from '../icon-button'; import { SWITCH_CONSTANTS } from '../switch'; const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}label`; @@ -16,7 +18,9 @@ const selectors = { const labelableChildSelectors = [ CHECKBOX_CONSTANTS.elementName, - SWITCH_CONSTANTS.elementName + SWITCH_CONSTANTS.elementName, + BUTTON_CONSTANTS.elementName, + ICON_BUTTON_CONSTANTS.elementName ]; export const LABEL_CONSTANTS = { diff --git a/src/lib/label/label-foundation.ts b/src/lib/label/label-foundation.ts index d2108d747..ac216e7f8 100644 --- a/src/lib/label/label-foundation.ts +++ b/src/lib/label/label-foundation.ts @@ -30,7 +30,7 @@ export class LabelFoundation implements ILabelFoundation { public initialize(): void { this._adapter.addSlotChangeListener(this._slotChangeListener); - this._adapter.trySetTarget(null); + this._adapter.trySetTarget(this._for ?? null); if (this._adapter.hasTargetElement()) { this._connect(); } diff --git a/src/lib/linear-progress/linear-progress.scss b/src/lib/linear-progress/linear-progress.scss index 8d7225326..9814f9748 100644 --- a/src/lib/linear-progress/linear-progress.scss +++ b/src/lib/linear-progress/linear-progress.scss @@ -125,8 +125,8 @@ $rtl-selectors: ( @mixin theme($theme) { :host([theme=#{$theme}]) { .forge-linear-progress { - @include override(indicator-color, theme.variable($theme)); - @include override(track-color, theme.variable(#{$theme}-container)); + @include override(indicator-color, theme.variable($theme), value); + @include override(track-color, theme.variable(#{$theme}-container), value); } } } diff --git a/src/lib/paginator/paginator-constants.ts b/src/lib/paginator/paginator-constants.ts index a94c5082b..61d6ac7b8 100644 --- a/src/lib/paginator/paginator-constants.ts +++ b/src/lib/paginator/paginator-constants.ts @@ -22,11 +22,11 @@ const selectors = { LABEL: `.${classes.LABEL}`, PAGE_SIZE_SELECT: `.${classes.PAGE_SIZE_OPTIONS}`, RANGE_LABEL: `.${classes.RANGE_LABEL}`, - FIRST_PAGE_BUTTON: `.${classes.FIRST_PAGE_BUTTON} > button`, + FIRST_PAGE_BUTTON: `.${classes.FIRST_PAGE_BUTTON}`, FIRST_PAGE_ICON_BUTTON: `.${classes.FIRST_PAGE_BUTTON}`, - PREVIOUS_PAGE_BUTTON: `.${classes.PREVIOUS_PAGE_BUTTON} > button`, - NEXT_PAGE_BUTTON: `.${classes.NEXT_PAGE_BUTTON} > button`, - LAST_PAGE_BUTTON: `.${classes.LAST_PAGE_BUTTON} > button`, + PREVIOUS_PAGE_BUTTON: `.${classes.PREVIOUS_PAGE_BUTTON}`, + NEXT_PAGE_BUTTON: `.${classes.NEXT_PAGE_BUTTON}`, + LAST_PAGE_BUTTON: `.${classes.LAST_PAGE_BUTTON}`, LAST_PAGE_ICON_BUTTON: `.${classes.LAST_PAGE_BUTTON}`, ROOT: `.${classes.ROOT}`, RANGE_LABEL_ALTERNATIVE: `.${classes.RANGE_LABEL_ALTERNATIVE}` diff --git a/src/lib/paginator/paginator.html b/src/lib/paginator/paginator.html index 84ed718f9..571ebc239 100644 --- a/src/lib/paginator/paginator.html +++ b/src/lib/paginator/paginator.html @@ -2,33 +2,31 @@
+ +
- - - Go to the first page + + + - - - Go to the previous page + Go to the first page + + + + Go to the previous page +
- - - Go to the next page + + - - - Go to the last page + Go to the next page + + + + Go to the last page
\ No newline at end of file diff --git a/src/lib/paginator/paginator.scss b/src/lib/paginator/paginator.scss index aa688ddfd..b65c8fd10 100644 --- a/src/lib/paginator/paginator.scss +++ b/src/lib/paginator/paginator.scss @@ -1,5 +1,4 @@ @use './mixins'; -@use '../icon-button/forge-icon-button'; @include mixins.core-styles; diff --git a/src/lib/quantity-field/quantity-field-constants.ts b/src/lib/quantity-field/quantity-field-constants.ts index f3e7b0ae5..100e09013 100644 --- a/src/lib/quantity-field/quantity-field-constants.ts +++ b/src/lib/quantity-field/quantity-field-constants.ts @@ -25,8 +25,8 @@ const selectors = { ROOT: `.${classes.ROOT}`, INCREMENT_BUTTON_SLOT: `slot[name=${slots.INCREMENT_BUTTON}]`, DECREMENT_BUTTON_SLOT: `slot[name=${slots.DECREMENT_BUTTON}]`, - INCREMENT_BUTTON: `[slot=${slots.INCREMENT_BUTTON}] button, button[slot=${slots.INCREMENT_BUTTON}]`, - DECREMENT_BUTTON: `[slot=${slots.DECREMENT_BUTTON}] button, button[slot=${slots.DECREMENT_BUTTON}]`, + INCREMENT_BUTTON: `[slot=${slots.INCREMENT_BUTTON}], button[slot=${slots.INCREMENT_BUTTON}]`, + DECREMENT_BUTTON: `[slot=${slots.DECREMENT_BUTTON}], button[slot=${slots.DECREMENT_BUTTON}]`, TEXT_FIELD: 'forge-text-field', INPUT: 'input[type=number]', LABEL: `[slot=${slots.LABEL}]`, diff --git a/src/lib/slider/slider.ts b/src/lib/slider/slider.ts index 54ef87fdb..d372d9339 100644 --- a/src/lib/slider/slider.ts +++ b/src/lib/slider/slider.ts @@ -1,6 +1,6 @@ import { attachShadowTemplate, coerceBoolean, coerceNumber, CustomElement, FoundationProperty, toggleAttribute } from '@tylertech/forge-core'; import { internals } from '../constants'; -import { BaseFormComponent, IBaseFormComponent } from '../core/base/base-component'; +import { BaseFormComponent, IBaseFormComponent } from '../core/base/base-form-component'; import { FocusIndicatorComponent } from '../focus-indicator/focus-indicator'; import { StateLayerComponent } from '../state-layer/state-layer'; import { SliderAdapter } from './slider-adapter'; @@ -10,7 +10,7 @@ import { SliderFoundation } from './slider-foundation'; import template from './slider.html'; import styles from './slider.scss'; -export interface ISliderComponent extends IBaseFormComponent | null> { +export interface ISliderComponent extends IBaseFormComponent { valueStart: number; valueEnd: number; label: string; diff --git a/src/lib/split-button/split-button-adapter.ts b/src/lib/split-button/split-button-adapter.ts index 2e5423efb..34a29e16b 100644 --- a/src/lib/split-button/split-button-adapter.ts +++ b/src/lib/split-button/split-button-adapter.ts @@ -1,9 +1,11 @@ -import { ButtonVariant, BUTTON_CONSTANTS, IButtonComponent } from '../button'; +import { ButtonTheme, BUTTON_CONSTANTS, IButtonComponent } from '../button'; import { BaseAdapter, IBaseAdapter } from '../core/base/base-adapter'; import { ISplitButtonComponent } from './split-button'; +import { SplitButtonVariant } from './split-button-constants'; export interface ISplitButtonAdapter extends IBaseAdapter { - setVariant(variant: ButtonVariant): void; + setVariant(variant: SplitButtonVariant): void; + setTheme(theme: ButtonTheme): void; setDisabled(value: boolean): void; setDense(value: boolean): void; setPill(value: boolean): void; @@ -41,6 +43,7 @@ export class SplitButtonAdapter extends BaseAdapter imple addedButtons.forEach(button => { button.variant = this._component.variant; + button.theme = this._component.theme; button.disabled = this._component.disabled; button.dense = this._component.dense; }); @@ -55,11 +58,16 @@ export class SplitButtonAdapter extends BaseAdapter imple this._buttonChangeObserver = undefined; } - public setVariant(variant: ButtonVariant): void { + public setVariant(variant: SplitButtonVariant): void { const buttons = this._getButtons(); buttons.forEach(button => button.variant = variant); } + public setTheme(theme: ButtonTheme): void { + const buttons = this._getButtons(); + buttons.forEach(button => button.theme = theme); + } + public setDisabled(value: boolean): void { const buttons = this._getButtons(); buttons.forEach(button => button.disabled = value); diff --git a/src/lib/split-button/split-button-constants.ts b/src/lib/split-button/split-button-constants.ts index 435b2dc47..70d5c6cdf 100644 --- a/src/lib/split-button/split-button-constants.ts +++ b/src/lib/split-button/split-button-constants.ts @@ -1,20 +1,25 @@ -import { ButtonVariant } from '../button'; +import { ButtonTheme, ButtonVariant } from '../button'; import { COMPONENT_NAME_PREFIX } from '../constants'; const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}split-button`; const attributes = { VARIANT: 'variant', + THEME: 'theme', DISABLED: 'disabled', DENSE: 'dense', PILL: 'pill' }; +const defaults = { + DEFAULT_VARIANT: 'text' as SplitButtonVariant, + DEFAULT_THEME: 'primary' as ButtonTheme +}; + export const SPLIT_BUTTON_CONSTANTS = { elementName, - attributes + attributes, + defaults }; -export const DEFAULT_VARIANT = 'text'; - -export type SplitButtonVariant = Extract; +export type SplitButtonVariant = Extract; diff --git a/src/lib/split-button/split-button-foundation.ts b/src/lib/split-button/split-button-foundation.ts index 2ee5ba5c9..47a251356 100644 --- a/src/lib/split-button/split-button-foundation.ts +++ b/src/lib/split-button/split-button-foundation.ts @@ -1,16 +1,19 @@ import { ICustomElementFoundation } from '@tylertech/forge-core'; +import { ButtonTheme } from '../button/button-constants'; import { ISplitButtonAdapter } from './split-button-adapter'; -import { DEFAULT_VARIANT, SplitButtonVariant, SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; +import { SplitButtonVariant, SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; export interface ISplitButtonFoundation extends ICustomElementFoundation { variant: SplitButtonVariant; + theme: ButtonTheme; disabled: boolean; dense: boolean; pill: boolean; } export class SplitButtonFoundation implements ISplitButtonFoundation { - private _variant: SplitButtonVariant = DEFAULT_VARIANT; + private _variant: SplitButtonVariant = SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT; + public _theme: ButtonTheme = SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_THEME; private _disabled = false; private _dense = false; private _pill = false; @@ -21,6 +24,7 @@ export class SplitButtonFoundation implements ISplitButtonFoundation { this._adapter.startButtonObserver(); this._adapter.setVariant(this._variant); + this._adapter.setTheme(this._theme); this._adapter.setDisabled(this._disabled); this._adapter.setDense(this._dense); this._adapter.setPill(this._pill); @@ -35,12 +39,23 @@ export class SplitButtonFoundation implements ISplitButtonFoundation { } public set variant(value: SplitButtonVariant) { if (this._variant !== value) { - this._variant = value ?? DEFAULT_VARIANT; + this._variant = value ?? SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT; this._adapter.setVariant(value); this._adapter.setHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.VARIANT, this._variant); } } + public get theme(): ButtonTheme { + return this._theme; + } + public set theme(value: ButtonTheme) { + if (this._theme !== value) { + this._theme = value ?? SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_THEME; + this._adapter.setTheme(this._theme); + this._adapter.setHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.THEME, this._theme); + } + } + public get disabled(): boolean { return this._disabled; } diff --git a/src/lib/split-button/split-button.scss b/src/lib/split-button/split-button.scss index 312d645c0..ba9a57bd5 100644 --- a/src/lib/split-button/split-button.scss +++ b/src/lib/split-button/split-button.scss @@ -1,6 +1,7 @@ @use './configuration'; @use './token-utils' as *; @use '../button'; +@use '../icon-button'; @use '../focus-indicator'; // @@ -34,8 +35,8 @@ ::slotted(:first-child) { @include button.provide-theme(( - border-top-right-radius: 0, - border-bottom-right-radius: 0 + shape-start-end-radius: 0, + shape-end-end-radius: 0 )); @include focus-indicator.provide-theme(( @@ -56,11 +57,10 @@ ::slotted(:last-child) { @include button.provide-theme(( - border-top-left-radius: 0, - border-bottom-left-radius: 0 + shape-start-start-radius: 0, + shape-end-start-radius: 0 )); - @include focus-indicator.provide-theme(( shape-start-start: 0, shape-end-start: 0, @@ -68,15 +68,6 @@ )); } -// -// Flat & Raised -// - -:host(:is([variant=flat], [variant=raised], :not([variant]))) { - ::slotted(:not(:last-child)) { - margin-inline-end: #{token(gap)}; - } -} // // Outlined @@ -87,17 +78,15 @@ margin-inline-start: calc(-1 * #{token(gap)}); } - @include override(focus-indicator-divider-offset, 0px); // Required unit + @include override(focus-indicator-divider-offset, 0px, value); // Required unit } // -// Disabled +// Tonal, Filled, and Raised // -:host(:is([variant=flat], [variant=raised], :not([variant]))[disabled]) { +:host(:is([variant=tonal],[variant=filled],[variant=raised],:not([variant]))) { ::slotted(:not(:last-child)) { - &::after { - @include override(divider-color, disabled-divider-color); - } + margin-inline-end: #{token(gap)}; } } diff --git a/src/lib/split-button/split-button.test.ts b/src/lib/split-button/split-button.test.ts index 614180abf..7273d0df8 100644 --- a/src/lib/split-button/split-button.test.ts +++ b/src/lib/split-button/split-button.test.ts @@ -3,7 +3,7 @@ import { elementUpdated, fixture, html } from '@open-wc/testing'; import './split-button'; import { ISplitButtonComponent } from './split-button'; -import { DEFAULT_VARIANT, SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; +import { SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; describe('SplitButton', () => { it('should initialize', async () => { @@ -26,7 +26,8 @@ describe('SplitButton', () => { const buttons = el.querySelectorAll('forge-button'); buttons.forEach(button => { - expect(button.variant).to.equal(DEFAULT_VARIANT); + expect(button.variant).to.equal(SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_VARIANT); + expect(button.theme).to.equal(SPLIT_BUTTON_CONSTANTS.defaults.DEFAULT_THEME); expect(button.disabled).to.be.false; expect(button.dense).to.be.false; expect(button.pill).to.be.false; @@ -35,13 +36,14 @@ describe('SplitButton', () => { it('should initialize with new state on buttons', async () => { const el = await fixture(html` - + First Second `); expect(el.variant).to.equal('outlined'); + expect(el.theme).to.equal('error'); expect(el.disabled).to.be.true; expect(el.dense).to.be.true; expect(el.pill).to.be.true; @@ -49,6 +51,7 @@ describe('SplitButton', () => { const buttons = el.querySelectorAll('forge-button'); buttons.forEach(button => { expect(button.variant).to.equal('outlined'); + expect(button.theme).to.equal('error'); expect(button.disabled).to.be.true; expect(button.dense).to.be.true; expect(button.pill).to.be.true; @@ -71,6 +74,22 @@ describe('SplitButton', () => { buttons.forEach(button => expect(button.variant).to.equal('raised')); }); + it('should update theme on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + el.theme = 'error'; + + expect(el.hasAttribute(SPLIT_BUTTON_CONSTANTS.attributes.THEME)).to.be.true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => expect(button.theme).to.equal('error')); + }); + it('should update disabled on buttons', async () => { const el = await fixture(html` diff --git a/src/lib/split-button/split-button.ts b/src/lib/split-button/split-button.ts index ca830bf24..24cde67bc 100644 --- a/src/lib/split-button/split-button.ts +++ b/src/lib/split-button/split-button.ts @@ -1,5 +1,5 @@ import { attachShadowTemplate, coerceBoolean, CustomElement, FoundationProperty } from '@tylertech/forge-core'; -import { ButtonComponent } from '../button'; +import { ButtonComponent, ButtonTheme } from '../button'; import { BaseComponent, IBaseComponent } from '../core/base/base-component'; import { SplitButtonAdapter } from './split-button-adapter'; import { SplitButtonVariant, SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; @@ -10,6 +10,7 @@ import styles from './split-button.scss'; export interface ISplitButtonComponent extends IBaseComponent { variant: SplitButtonVariant; + theme: ButtonTheme; disabled: boolean; dense: boolean; pill: boolean; @@ -23,6 +24,27 @@ declare global { /** * @tag forge-split-button + * + * @summary Split buttons are used for splitting an action into two parts. + * + * @property {SplitButtonVariant} variant - The variant of the buttons. Valid values are `text`, `outlined`, `tonal`, `filled`, and `raised`. + * @property {ButtonTheme} theme - The theme of the buttons. Valid values are `primary`, `secondary`, `tertiary`, `success`, `error`, `warning`, `info`. + * @property {boolean} disabled - Whether or not the buttons are disabled. + * @property {boolean} dense - Whether or not the buttons are dense. + * @property {boolean} pill - Whether or not the buttons are pill-shaped. + * + * @attribute {SplitButtonVariant} variant - The variant of the buttons. Valid values are `text`, `outlined`, `tonal`, `filled`, and `raised`. + * @attribute {ButtonTheme} theme - The theme of the buttons. Valid values are `primary`, `secondary`, `tertiary`, `success`, `error`, `warning`, `info`. + * @attribute {boolean} disabled - Whether or not the buttons are disabled. + * @attribute {boolean} dense - Whether or not the buttons are dense. + * @attribute {boolean} pill - Whether or not the buttons are pill-shaped. + * + * @cssproperty --forge-split-button-min-width - The minimum width of the slotted buttons. + * @cssproperty --forge-split-button-gap - The gap between the slotted buttons. + * @cssproperty --forge-split-button-focus-indicator-offset - The offset of the focus indicator around the buttons. + * @cssproperty --forge-split-button-focus-indicator-divider-offset - The offset of the focus indicator divider when using outlined buttons. + * + * @slot - This is a default/unnamed slot. */ @CustomElement({ name: SPLIT_BUTTON_CONSTANTS.elementName, @@ -34,6 +56,7 @@ export class SplitButtonComponent extends BaseComponent implements ISplitButtonC public static get observedAttributes(): string[] { return [ SPLIT_BUTTON_CONSTANTS.attributes.VARIANT, + SPLIT_BUTTON_CONSTANTS.attributes.THEME, SPLIT_BUTTON_CONSTANTS.attributes.DISABLED, SPLIT_BUTTON_CONSTANTS.attributes.DENSE, SPLIT_BUTTON_CONSTANTS.attributes.PILL @@ -61,6 +84,9 @@ export class SplitButtonComponent extends BaseComponent implements ISplitButtonC case SPLIT_BUTTON_CONSTANTS.attributes.VARIANT: this.variant = newValue as SplitButtonVariant; break; + case SPLIT_BUTTON_CONSTANTS.attributes.THEME: + this.theme = newValue as ButtonTheme; + break; case SPLIT_BUTTON_CONSTANTS.attributes.DISABLED: this.disabled = coerceBoolean(newValue); break; @@ -76,6 +102,9 @@ export class SplitButtonComponent extends BaseComponent implements ISplitButtonC @FoundationProperty() public declare variant: SplitButtonVariant; + @FoundationProperty() + public declare theme: ButtonTheme; + @FoundationProperty() public declare disabled: boolean; diff --git a/src/lib/switch/switch.scss b/src/lib/switch/switch.scss index 3c78f5d32..f3e5b0a4a 100644 --- a/src/lib/switch/switch.scss +++ b/src/lib/switch/switch.scss @@ -177,7 +177,7 @@ // Reduced motion @media (prefers-reduced-motion) { .switch { - @include override(animation-duration, 0s); + @include override(animation-duration, 0s, value); } } diff --git a/src/lib/switch/switch.ts b/src/lib/switch/switch.ts index 6cff1cad3..b68d85ff6 100644 --- a/src/lib/switch/switch.ts +++ b/src/lib/switch/switch.ts @@ -1,5 +1,5 @@ import { CustomElement, FoundationProperty, attachShadowTemplate, coerceBoolean, isDefined, isString, toggleAttribute } from '@tylertech/forge-core'; -import { BaseNullableFormComponent, IBaseNullableFormComponent } from '../core'; +import { BaseNullableFormComponent, IBaseNullableFormComponent } from '../core/base/base-nullable-form-component'; import { FocusIndicatorComponent } from '../focus-indicator/focus-indicator'; import { StateLayerComponent } from '../state-layer/state-layer'; import { SwitchAdapter } from './switch-adapter'; diff --git a/src/lib/tabs/tab-bar/tab-bar-adapter.ts b/src/lib/tabs/tab-bar/tab-bar-adapter.ts index 7fec6dedc..08a770db7 100644 --- a/src/lib/tabs/tab-bar/tab-bar-adapter.ts +++ b/src/lib/tabs/tab-bar/tab-bar-adapter.ts @@ -1,5 +1,6 @@ import { getShadowElement, toggleAttribute } from '@tylertech/forge-core'; +import { IIconButtonComponent } from '../../icon-button/icon-button'; import { tylIconKeyboardArrowLeft, tylIconKeyboardArrowRight, tylIconKeyboardArrowUp, tylIconKeyboardArrowDown } from '@tylertech/tyler-icons/standard'; import { BaseAdapter, IBaseAdapter } from '../../core/base/base-adapter'; import { ITabComponent } from '../tab/tab'; @@ -41,8 +42,8 @@ export class TabBarAdapter extends BaseAdapter implements ITab private readonly _container: HTMLElement; private readonly _scrollContainer: HTMLElement; private _resizeObserver: ResizeObserver | undefined; - private _backwardScrollButton: HTMLElement | undefined; - private _forwardScrollButton: HTMLElement | undefined; + private _backwardScrollButton: IIconButtonComponent | undefined; + private _forwardScrollButton: IIconButtonComponent | undefined; constructor(component: ITabBarComponent) { super(component); @@ -75,11 +76,11 @@ export class TabBarAdapter extends BaseAdapter implements ITab } public setScrollBackwardButtonListener(listener: EventListener): void { - this._backwardScrollButton?.querySelector('button')?.addEventListener('click', listener); + this._backwardScrollButton?.addEventListener('click', listener); } public setScrollForwardButtonListener(listener: EventListener): void { - this._forwardScrollButton?.querySelector('button')?.addEventListener('click', listener); + this._forwardScrollButton?.addEventListener('click', listener); } public addSlotListener(listener: EventListener): void { @@ -147,23 +148,20 @@ export class TabBarAdapter extends BaseAdapter implements ITab } public syncScrollButtons({ backwardEnabled, forwardEnabled }: ITabBarScrollButtonState): void { - const backButton = this._backwardScrollButton?.querySelector('button'); - const forwardButton = this._forwardScrollButton?.querySelector('button'); - - if (backButton) { + if (this._backwardScrollButton) { const disabled = !backwardEnabled; - if (disabled && backButton.matches(':focus')) { - forwardButton?.focus(); + if (disabled && this._backwardScrollButton.matches(':focus')) { + this._forwardScrollButton?.focus(); } - backButton.disabled = disabled; + this._backwardScrollButton.disabled = disabled; } - if (forwardButton) { + if (this._forwardScrollButton) { const disabled = !forwardEnabled; - if (disabled && forwardButton.matches(':focus')) { - backButton?.focus(); + if (disabled && this._forwardScrollButton.matches(':focus')) { + this._backwardScrollButton?.focus(); } - forwardButton.disabled = disabled; + this._forwardScrollButton.disabled = disabled; } } @@ -187,19 +185,17 @@ export class TabBarAdapter extends BaseAdapter implements ITab } } - private _createScrollButton(iconName: string): HTMLElement { + private _createScrollButton(iconName: string): IIconButtonComponent { const iconButton = document.createElement('forge-icon-button'); iconButton.classList.add(TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON); - - const button = document.createElement('button'); - button.type = 'button'; - button.tabIndex = -1; - button.setAttribute('aria-hidden', 'true'); - iconButton.appendChild(button); + iconButton.shape = 'squared'; + iconButton.type = 'button'; + iconButton.tabIndex = -1; + iconButton.setAttribute('aria-hidden', 'true'); const icon = document.createElement('forge-icon'); icon.name = iconName; - button.appendChild(icon); + iconButton.appendChild(icon); return iconButton; } diff --git a/src/lib/tabs/tab-bar/tab-bar.scss b/src/lib/tabs/tab-bar/tab-bar.scss index bda4b53b3..2862bc155 100644 --- a/src/lib/tabs/tab-bar/tab-bar.scss +++ b/src/lib/tabs/tab-bar/tab-bar.scss @@ -1,6 +1,6 @@ @use './core'; +@use '../../icon-button'; @use './configuration'; -@use '../../icon-button/forge-icon-button'; @use './token-utils' as *; // @@ -39,6 +39,12 @@ @include core.slotted-selected; } +forge-icon-button { + @include icon-button.provide-theme(( + shape-squared: 0px // Requires unit + )); +} + // // Vertical // @@ -79,25 +85,25 @@ :host([clustered]) { .container { - @include override(justify, flex-start); - @include override(stretch, 0); + @include override(justify, flex-start, value); + @include override(stretch, 0, value); } } :host([clustered=start]) { .container { - @include override(justify, flex-start); + @include override(justify, flex-start, value); } } :host([clustered=center]) { .container { - @include override(justify, center); + @include override(justify, center, value); } } :host([clustered=end]) { .container { - @include override(justify, flex-end); + @include override(justify, flex-end, value); } } diff --git a/src/lib/tabs/tabs.test.ts b/src/lib/tabs/tabs.test.ts index cbe076357..0ff038391 100644 --- a/src/lib/tabs/tabs.test.ts +++ b/src/lib/tabs/tabs.test.ts @@ -9,9 +9,9 @@ import { TAB_BAR_CONSTANTS } from './tab-bar'; import type { ITabBarComponent } from './tab-bar/tab-bar'; import type { ITabComponent } from './tab/tab'; import type { IIconComponent } from '../icon/icon'; +import { IStateLayerComponent, STATE_LAYER_CONSTANTS } from '../state-layer'; import './tab-bar/tab-bar'; -import { IStateLayerComponent, STATE_LAYER_CONSTANTS } from '../state-layer'; describe('Tabs', () => { it('should contain shadow root', async () => { @@ -662,19 +662,19 @@ class TabsHarness extends TestHarness { } public get backwardScrollButton(): HTMLButtonElement { - return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:first-child button`) as HTMLButtonElement; + return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:first-child`) as HTMLButtonElement; } public get forwardScrollButton(): HTMLButtonElement { - return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:last-child button`) as HTMLButtonElement; + return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:last-child`) as HTMLButtonElement; } public get backwardScrollButtonIcon(): IIconComponent { - return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:first-child button forge-icon`) as IIconComponent; + return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:first-child forge-icon`) as IIconComponent; } public get forwardScrollButtonIcon(): IIconComponent { - return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:last-child button forge-icon`) as IIconComponent; + return this.containerElement.querySelector(`.${TAB_BAR_CONSTANTS.classes.SCROLL_BUTTON}:last-child forge-icon`) as IIconComponent; } } diff --git a/src/lib/time-picker/time-picker-adapter.ts b/src/lib/time-picker/time-picker-adapter.ts index a9725822a..db4768aa7 100644 --- a/src/lib/time-picker/time-picker-adapter.ts +++ b/src/lib/time-picker/time-picker-adapter.ts @@ -124,19 +124,15 @@ export class TimePickerAdapter extends BaseAdapter impleme const iconButtonElement = document.createElement(ICON_BUTTON_CONSTANTS.elementName) as IIconButtonComponent; iconButtonElement.slot = 'trailing'; - iconButtonElement.dense = true; - iconButtonElement.densityLevel = 3; + iconButtonElement.density = 'medium'; + iconButtonElement.type = 'button'; + iconButtonElement.tabIndex = -1; iconButtonElement.style.marginRight = '4px'; // Override default trailing slot margin in text-field - - const buttonElement = document.createElement('button'); - buttonElement.type = 'button'; - buttonElement.tabIndex = -1; - buttonElement.setAttribute('aria-label', 'Toggle time dropdown'); + iconButtonElement.setAttribute('aria-label', 'Toggle time dropdown'); const iconElement = document.createElement(ICON_CONSTANTS.elementName) as IIconComponent; iconElement.name = 'clock_outline'; - buttonElement.appendChild(iconElement); - iconButtonElement.appendChild(buttonElement); + iconButtonElement.appendChild(iconElement); textField.appendChild(iconButtonElement); this._toggleElement = iconButtonElement; diff --git a/src/lib/toast/toast.html b/src/lib/toast/toast.html index 2d865d47a..3560b998e 100644 --- a/src/lib/toast/toast.html +++ b/src/lib/toast/toast.html @@ -4,10 +4,8 @@
- - + +
diff --git a/src/lib/toast/toast.scss b/src/lib/toast/toast.scss index 0698b38e2..3055df751 100644 --- a/src/lib/toast/toast.scss +++ b/src/lib/toast/toast.scss @@ -1,5 +1,4 @@ @use './mixins'; -@use '../icon-button/forge-icon-button'; @include mixins.core-styles; diff --git a/src/stories/src/components/autocomplete/code/autocomplete-default.ts b/src/stories/src/components/autocomplete/code/autocomplete-default.ts index 1c13b063a..b8558741e 100644 --- a/src/stories/src/components/autocomplete/code/autocomplete-default.ts +++ b/src/stories/src/components/autocomplete/code/autocomplete-default.ts @@ -7,9 +7,7 @@ export const AutocompleteDefaultCodeHtml = () => { - + diff --git a/src/stories/src/components/button-area/code/button-area-default.ts b/src/stories/src/components/button-area/code/button-area-default.ts index 816534ef5..583848088 100644 --- a/src/stories/src/components/button-area/code/button-area-default.ts +++ b/src/stories/src/components/button-area/code/button-area-default.ts @@ -8,12 +8,10 @@ export const ButtonAreaDefaultCodeHtml = () => { Heading Content - - - Favorite + + + Favorite @@ -54,12 +52,10 @@ export const ButtonAreaInExpansionPanelCodeHtml = () => { Heading Content - - - Favorite + + + Favorite diff --git a/src/stories/src/components/card/code/card-styled.ts b/src/stories/src/components/card/code/card-styled.ts index 1f5b1b27c..036b9357f 100644 --- a/src/stories/src/components/card/code/card-styled.ts +++ b/src/stories/src/components/card/code/card-styled.ts @@ -5,9 +5,7 @@ export const CardStyledCodeHtml = () => {

This is the card title

- +
diff --git a/src/stories/src/components/dialog/code/dialog-complex.ts b/src/stories/src/components/dialog/code/dialog-complex.ts index 11f12dff2..1f918b26f 100644 --- a/src/stories/src/components/dialog/code/dialog-complex.ts +++ b/src/stories/src/components/dialog/code/dialog-complex.ts @@ -2,10 +2,8 @@ export const DialogComplexCodeHtml = () => `