diff --git a/src/dev/pages/button/button.scss b/src/dev/pages/button/button.scss index aa6aa1c5e..b19b43576 100644 --- a/src/dev/pages/button/button.scss +++ b/src/dev/pages/button/button.scss @@ -36,8 +36,8 @@ h3 { --_custom-button-background: var(--forge-theme-primary); @include button.provide-theme(( - height: 56px, - dense-height: 40px, + min-height: 56px, + dense-min-height: 40px, focus-indicator-offset: 4px, shadow: elevation.value(2), hover-shadow: elevation.value(4), diff --git a/src/dev/pages/split-button/split-button.ejs b/src/dev/pages/split-button/split-button.ejs new file mode 100644 index 000000000..d7595e847 --- /dev/null +++ b/src/dev/pages/split-button/split-button.ejs @@ -0,0 +1,21 @@ +
+

Common

+ + Send + + + + +
+ +
+

Multiple

+ + Button one + Button two + Button three + Button four + +
+ + diff --git a/src/dev/pages/split-button/split-button.html b/src/dev/pages/split-button/split-button.html new file mode 100644 index 000000000..91219f16f --- /dev/null +++ b/src/dev/pages/split-button/split-button.html @@ -0,0 +1,25 @@ +<%- +include('./src/partials/page.ejs', { + page: { + title: 'Split button', + includePath: './pages/split-button/split-button.ejs', + options: [ + { + type: 'select', + label: 'Variant', + id: 'opt-variant', + defaultValue: 'text', + options: [ + { value: 'text', label: 'Text (default)' }, + { value: 'flat', label: 'Flat' }, + { value: 'raised', label: 'Raised' }, + { value: 'outlined', label: 'Outlined' } + ] + }, + { type: 'switch', label: 'Disabled', id: 'opt-disabled' }, + { type: 'switch', label: 'Dense', id: 'opt-dense' }, + { type: 'switch', label: 'Pill', id: 'opt-pill' } + ] + } +}) +%> diff --git a/src/dev/pages/split-button/split-button.scss b/src/dev/pages/split-button/split-button.scss new file mode 100644 index 000000000..0214463a9 --- /dev/null +++ b/src/dev/pages/split-button/split-button.scss @@ -0,0 +1,5 @@ +forge-split-button[variant]:not([variant=text]) { + .primary-action { + min-width: 100px; + } +} diff --git a/src/dev/pages/split-button/split-button.ts b/src/dev/pages/split-button/split-button.ts new file mode 100644 index 000000000..673d3ddd4 --- /dev/null +++ b/src/dev/pages/split-button/split-button.ts @@ -0,0 +1,42 @@ +import '$src/shared'; +import '@tylertech/forge/split-button'; +import { IconRegistry } from '@tylertech/forge/icon'; +import type { ISplitButtonComponent, SplitButtonVariant } from '@tylertech/forge/split-button'; +import type { ISelectComponent } from '@tylertech/forge/select'; +import type { ISwitchComponent } from '@tylertech/forge/switch'; +import type { IMenuComponent } from '@tylertech/forge/menu'; +import { tylIconArrowDropDown, tylIconScheduleSend, tylIconDeleteOutline, tylIconBookmarkBorder } from '@tylertech/tyler-icons/standard'; +import './split-button.scss'; + +IconRegistry.define([tylIconArrowDropDown, tylIconScheduleSend, tylIconDeleteOutline, tylIconBookmarkBorder]); + +const splitMenu = document.querySelector('#split-menu') as IMenuComponent; +splitMenu.options = [ + { label: 'Schedule send', value: 'schedule', leadingIcon: 'schedule_send', leadingIconType: 'component' }, + { label: 'Delete', value: 'delete', leadingIcon: 'delete_outline', leadingIconType: 'component' }, + { label: 'Save draft', value: 'save', leadingIcon: 'bookmark_border', leadingIconType: 'component' } +]; + +const variantSelect = document.querySelector('#opt-variant') as ISelectComponent; +variantSelect.addEventListener('change', ({ detail: variant }: CustomEvent) => { + getSplitButtons().forEach(splitButton => splitButton.variant = variant); +}); + +const disabledToggle = document.querySelector('#opt-disabled') as ISwitchComponent; +disabledToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + getSplitButtons().forEach(splitButton => splitButton.disabled = selected); +}); + +const denseToggle = document.querySelector('#opt-dense') as ISwitchComponent; +denseToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + getSplitButtons().forEach(splitButton => splitButton.dense = selected); +}); + +const pillToggle = document.querySelector('#opt-pill') as ISwitchComponent; +pillToggle.addEventListener('forge-switch-change', ({ detail: selected }) => { + getSplitButtons().forEach(splitButton => splitButton.pill = selected); +}); + +function getSplitButtons(): ISplitButtonComponent[] { + return Array.from(document.querySelectorAll('forge-split-button')); +} diff --git a/src/dev/src/components.json b/src/dev/src/components.json index 6b4b5ce17..f5a589b26 100644 --- a/src/dev/src/components.json +++ b/src/dev/src/components.json @@ -45,6 +45,7 @@ { "label": "Select", "path": "/pages/select/select.html", "tags": ["form", "field"] }, { "label": "Skeleton", "path": "/pages/skeleton/skeleton.html" }, { "label": "Slider", "path": "/pages/slider/slider.html", "tags": ["form"] }, + { "label": "Split button", "path": "/pages/split-button/split-button.html" }, { "label": "Split view", "path": "/pages/split-view/split-view.html", "tags": ["form"] }, { "label": "Stack", "path": "/pages/stack/stack.html" }, { "label": "State layer", "path": "/pages/state-layer/state-layer.html", "tags": ["ripple", "hover", "focus", "active", "pressed"] }, diff --git a/src/lib/button/_core.scss b/src/lib/button/_core.scss index 8dc49412e..17f0260cd 100644 --- a/src/lib/button/_core.scss +++ b/src/lib/button/_core.scss @@ -19,8 +19,8 @@ z-index: 0; box-sizing: border-box; + min-block-size: #{token(min-height)}; min-inline-size: #{token(min-width)}; - height: #{token(height)}; inline-size: 100%; border-width: #{token(border-width)}; border-style: #{token(border-style)}; @@ -108,7 +108,7 @@ @mixin link { @include override(color, link-color); - @include override(height, link-height); + @include override(min-height, link-min-height); @include override(padding, link-padding); @include override(focus-indicator-offset, link-focus-indicator-offset); @@ -154,7 +154,7 @@ } @mixin dense { - @include override(height, dense-height); + @include override(min-height, dense-min-height); } @mixin pill { diff --git a/src/lib/core/styles/tokens/button/_tokens.scss b/src/lib/core/styles/tokens/button/_tokens.scss index 6f3851e64..7fdfc8426 100644 --- a/src/lib/core/styles/tokens/button/_tokens.scss +++ b/src/lib/core/styles/tokens/button/_tokens.scss @@ -20,7 +20,7 @@ $tokens: ( shape: utils.module-val(button, shape, shape.variable(medium)), // Base - height: utils.module-val(button, height, 36px), + min-height: utils.module-val(button, min-height, 36px), min-width: utils.module-val(button, min-width, 64px), spacing: utils.module-val(button, spacing, spacing.variable(xsmall)), border-width: utils.module-val(button, border-width, medium), @@ -70,7 +70,7 @@ $tokens: ( // Link link-color: utils.module-ref(button, link-color, primary-color), link-text-decoration: utils.module-val(button, link-text-decoration, underline), - link-height: utils.module-val(button, link-height, auto), + link-min-height: utils.module-val(button, link-min-height, auto), link-padding: utils.module-val(button, link-padding, 0), link-line-height: utils.module-val(button, link-line-height, normal), link-width: utils.module-val(button, link-width, auto), @@ -87,7 +87,7 @@ $tokens: ( disabled-shadow: utils.module-val(button, disabled-shadow, none), // Dense - dense-height: utils.module-val(button, dense-height, 24px), + dense-min-height: utils.module-val(button, dense-min-height, 24px), // Pill pill-shape: utils.module-val(button, pill-shape, shape.variable(full)), diff --git a/src/lib/core/styles/tokens/focus-indicator/_tokens.scss b/src/lib/core/styles/tokens/focus-indicator/_tokens.scss index 5fbf7a43f..328e75a78 100644 --- a/src/lib/core/styles/tokens/focus-indicator/_tokens.scss +++ b/src/lib/core/styles/tokens/focus-indicator/_tokens.scss @@ -7,18 +7,22 @@ @use '../../utils'; $tokens: ( + width: utils.module-val(focus-indicator, width, border.variable(medium)), active-width: utils.module-val(focus-indicator, active-width, 6px), + color: utils.module-val(focus-indicator, color, theme.variable(primary)), - duration: utils.module-val(focus-indicator, duration, animation.variable(duration-long4)), - outward-offset: utils.module-val(focus-indicator, outward-offset, spacing.variable(xxsmall)), - inward-offset: utils.module-val(focus-indicator, inward-offset, 0px), // Requires unit shape: utils.module-val(focus-indicator, shape, shape.variable(extra-small)), // Requires unit - width: utils.module-val(focus-indicator, width, border.variable(medium)), + + duration: utils.module-val(focus-indicator, duration, animation.variable(duration-long4)), easing: utils.module-val(focus-indicator, easing, animation.variable(easing-emphasized)), + shape-start-start: utils.module-ref(focus-indicator, shape-start-start, shape), shape-start-end: utils.module-ref(focus-indicator, shape-start-end, shape), shape-end-end: utils.module-ref(focus-indicator, shape-end-end, shape), shape-end-start: utils.module-ref(focus-indicator, shape-end-start, shape), + + outward-offset: utils.module-val(focus-indicator, outward-offset, spacing.variable(xxsmall)), + inward-offset: utils.module-val(focus-indicator, inward-offset, 0px), // Requires unit offset-block: utils.module-val(focus-indicator, offset-block, 0), offset-inline: utils.module-val(focus-indicator, offset-inline, 0) ) !default; diff --git a/src/lib/core/styles/tokens/split-button/_tokens.scss b/src/lib/core/styles/tokens/split-button/_tokens.scss new file mode 100644 index 000000000..decd9f44a --- /dev/null +++ b/src/lib/core/styles/tokens/split-button/_tokens.scss @@ -0,0 +1,16 @@ +@use 'sass:map'; +@use '../../utils'; +@use '../../border'; +@use '../button/tokens' as button; + +$tokens: ( + min-width: utils.module-val(split-button, min-width, 0), + gap: utils.module-val(split-button, gap, border.variable(thin)), + + focus-indicator-offset: utils.module-val(split-button, focus-indicator-offset, button.get(focus-indicator-offset)), + focus-indicator-divider-offset: utils.module-ref(split-button, focus-indicator-divider-offset, gap) +) !default; + +@function get($key) { + @return map.get($tokens, $key); +} diff --git a/src/lib/core/styles/tokens/typography/_tokens.label.scss b/src/lib/core/styles/tokens/typography/_tokens.label.scss index 7b674d215..29b9015fe 100644 --- a/src/lib/core/styles/tokens/typography/_tokens.label.scss +++ b/src/lib/core/styles/tokens/typography/_tokens.label.scss @@ -13,7 +13,6 @@ $label: utils.inherit-map(core.$base, ( $button: utils.inherit-map(core.$base, ( font-size: type-utils.font-size-relative(button, font-size, '0875'), font-weight: weight.value(medium), - line-height: type-utils.font-size-relative(button, line-height, '2250'), letter-spacing: type-utils.calc-letter-spacing(1, scale.value('0875')) )) !default; diff --git a/src/lib/index.ts b/src/lib/index.ts index dc67a617c..b7ff4a806 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -63,6 +63,7 @@ import { ScaffoldComponent } from './scaffold'; import { OptionComponent, OptionGroupComponent, SelectComponent } from './select'; import { SkeletonComponent } from './skeleton'; import { SliderComponent } from './slider'; +import { SplitButtonComponent } from './split-button'; import { SplitViewComponent } from './split-view'; import { StateLayerComponent } from './state-layer'; import { StepComponent, StepperComponent } from './stepper'; @@ -131,6 +132,7 @@ export * from './scaffold'; export * from './select'; export * from './skeleton'; export * from './slider'; +export * from './split-button'; export * from './split-view'; export * from './state-layer'; export * from './stepper'; diff --git a/src/lib/menu/menu.scss b/src/lib/menu/menu.scss index 654c39f9e..e320ba1e9 100644 --- a/src/lib/menu/menu.scss +++ b/src/lib/menu/menu.scss @@ -1,5 +1,5 @@ :host { - display: inline-block; + display: inline-flex; } :host([hidden]) { diff --git a/src/lib/slider/_token-utils.scss b/src/lib/slider/_token-utils.scss index 30c02e3d8..0273aef59 100644 --- a/src/lib/slider/_token-utils.scss +++ b/src/lib/slider/_token-utils.scss @@ -1,4 +1,3 @@ -@use '../core/styles/utils'; @use '../core/styles/tokens/slider/tokens'; @use '../core/styles/tokens/token-utils'; diff --git a/src/lib/slider/slider-adapter.ts b/src/lib/slider/slider-adapter.ts index 831bfff36..57b9625c7 100644 --- a/src/lib/slider/slider-adapter.ts +++ b/src/lib/slider/slider-adapter.ts @@ -35,7 +35,7 @@ export interface ISliderAdapter extends IBaseAdapter { setEndAriaLabel(value: string | null): void; } -export class SliderAdapter extends BaseAdapter { +export class SliderAdapter extends BaseAdapter implements ISliderAdapter { private readonly _rootElement: HTMLElement; private readonly _trackElement: HTMLElement; private readonly _handleContainerElement: HTMLElement; diff --git a/src/lib/split-button/_configuration.scss b/src/lib/split-button/_configuration.scss new file mode 100644 index 000000000..9a2308d29 --- /dev/null +++ b/src/lib/split-button/_configuration.scss @@ -0,0 +1,7 @@ +@use './token-utils' as *; + +@mixin configuration { + @include tokens; + + #{declare(focus-indicator-offset-adjusted)}: calc(#{token(focus-indicator-offset)} + #{token(focus-indicator-divider-offset)} * 2); +} diff --git a/src/lib/split-button/_token-utils.scss b/src/lib/split-button/_token-utils.scss new file mode 100644 index 000000000..bdc1dd928 --- /dev/null +++ b/src/lib/split-button/_token-utils.scss @@ -0,0 +1,25 @@ +@use '../core/styles/tokens/split-button/tokens'; +@use '../core/styles/tokens/token-utils'; + +$_module: split-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/split-button/build.json b/src/lib/split-button/build.json new file mode 100644 index 000000000..d6e9c5322 --- /dev/null +++ b/src/lib/split-button/build.json @@ -0,0 +1,4 @@ +{ + "$schema": "../../../node_modules/@tylertech/forge-cli/config/build-schema.json", + "extends": "../build.json" +} diff --git a/src/lib/split-button/index.scss b/src/lib/split-button/index.scss new file mode 100644 index 000000000..0391c8fac --- /dev/null +++ b/src/lib/split-button/index.scss @@ -0,0 +1,2 @@ +@forward './configuration'; +@forward './token-utils' show provide-theme; diff --git a/src/lib/split-button/index.ts b/src/lib/split-button/index.ts new file mode 100644 index 000000000..cb0d5fa5d --- /dev/null +++ b/src/lib/split-button/index.ts @@ -0,0 +1,12 @@ +import { defineCustomElement } from '@tylertech/forge-core'; + +import { SplitButtonComponent } from './split-button'; + +export * from './split-button-adapter'; +export * from './split-button-constants'; +export * from './split-button-foundation'; +export * from './split-button'; + +export function defineSplitButtonComponent(): void { + defineCustomElement(SplitButtonComponent); +} diff --git a/src/lib/split-button/split-button-adapter.ts b/src/lib/split-button/split-button-adapter.ts new file mode 100644 index 000000000..2e5423efb --- /dev/null +++ b/src/lib/split-button/split-button-adapter.ts @@ -0,0 +1,99 @@ +import { ButtonVariant, BUTTON_CONSTANTS, IButtonComponent } from '../button'; +import { BaseAdapter, IBaseAdapter } from '../core/base/base-adapter'; +import { ISplitButtonComponent } from './split-button'; + +export interface ISplitButtonAdapter extends IBaseAdapter { + setVariant(variant: ButtonVariant): void; + setDisabled(value: boolean): void; + setDense(value: boolean): void; + setPill(value: boolean): void; + startButtonObserver(): void; + destroyButtonObserver(): void; +} + +export class SplitButtonAdapter extends BaseAdapter implements ISplitButtonAdapter { + private _buttonChangeObserver: MutationObserver | undefined; + + constructor(component: ISplitButtonComponent) { + super(component); + } + + public startButtonObserver(): void { + // This observer is used to keep the buttons in sync with the split button state when they are added to DOM + this._buttonChangeObserver = new MutationObserver(mutations => { + // Find all `` elements that are contained within the added nodes + const addedButtons = mutations.reduce((buttons, { addedNodes }) => { + const addedButtonNodes = Array.from(addedNodes) + .filter(node => node.nodeType === Node.ELEMENT_NODE) + .map((node: HTMLElement) => { + if (node.nodeName.toLowerCase() === BUTTON_CONSTANTS.elementName) { + return node; + } + return node.querySelector(BUTTON_CONSTANTS.elementName); + }) + .filter(node => !!node) as IButtonComponent[]; + return buttons.concat(addedButtonNodes); + }, [] as IButtonComponent[]); + + if (!addedButtons.length) { + return; + } + + addedButtons.forEach(button => { + button.variant = this._component.variant; + button.disabled = this._component.disabled; + button.dense = this._component.dense; + }); + + this.setPill(this._component.pill); + }); + this._buttonChangeObserver.observe(this._component, { childList: true, subtree: true }); + } + + public destroyButtonObserver(): void { + this._buttonChangeObserver?.disconnect(); + this._buttonChangeObserver = undefined; + } + + public setVariant(variant: ButtonVariant): void { + const buttons = this._getButtons(); + buttons.forEach(button => button.variant = variant); + } + + public setDisabled(value: boolean): void { + const buttons = this._getButtons(); + buttons.forEach(button => button.disabled = value); + } + + public setDense(value: boolean): void { + const buttons = this._getButtons(); + buttons.forEach(button => button.dense = value); + } + + public setPill(value: boolean): void { + const buttons = this._getButtons(); + + // First we reset all the middle buttons to not be pill buttons + if (buttons.length > 2) { + Array.from(buttons) + .slice(1, buttons.length - 1) + .filter(({ pill }) => pill) + .forEach(button => button.pill = false); + } + + // Only the first and last buttons need to be pill shaped + const firstButton = buttons[0]; + if (firstButton) { + firstButton.pill = value; + } + + const lastButton = buttons[buttons.length - 1]; + if (lastButton) { + lastButton.pill = value; + } + } + + private _getButtons(): NodeListOf { + return this._component.querySelectorAll(BUTTON_CONSTANTS.elementName); + } +} diff --git a/src/lib/split-button/split-button-constants.ts b/src/lib/split-button/split-button-constants.ts new file mode 100644 index 000000000..435b2dc47 --- /dev/null +++ b/src/lib/split-button/split-button-constants.ts @@ -0,0 +1,20 @@ +import { ButtonVariant } from '../button'; +import { COMPONENT_NAME_PREFIX } from '../constants'; + +const elementName: keyof HTMLElementTagNameMap = `${COMPONENT_NAME_PREFIX}split-button`; + +const attributes = { + VARIANT: 'variant', + DISABLED: 'disabled', + DENSE: 'dense', + PILL: 'pill' +}; + +export const SPLIT_BUTTON_CONSTANTS = { + elementName, + attributes +}; + +export const DEFAULT_VARIANT = 'text'; + +export type SplitButtonVariant = Extract; diff --git a/src/lib/split-button/split-button-foundation.ts b/src/lib/split-button/split-button-foundation.ts new file mode 100644 index 000000000..2ee5ba5c9 --- /dev/null +++ b/src/lib/split-button/split-button-foundation.ts @@ -0,0 +1,76 @@ +import { ICustomElementFoundation } from '@tylertech/forge-core'; +import { ISplitButtonAdapter } from './split-button-adapter'; +import { DEFAULT_VARIANT, SplitButtonVariant, SPLIT_BUTTON_CONSTANTS } from './split-button-constants'; + +export interface ISplitButtonFoundation extends ICustomElementFoundation { + variant: SplitButtonVariant; + disabled: boolean; + dense: boolean; + pill: boolean; +} + +export class SplitButtonFoundation implements ISplitButtonFoundation { + private _variant: SplitButtonVariant = DEFAULT_VARIANT; + private _disabled = false; + private _dense = false; + private _pill = false; + + constructor(private readonly _adapter: ISplitButtonAdapter) {} + + public initialize(): void { + this._adapter.startButtonObserver(); + + this._adapter.setVariant(this._variant); + this._adapter.setDisabled(this._disabled); + this._adapter.setDense(this._dense); + this._adapter.setPill(this._pill); + } + + public destroy(): void { + this._adapter.destroyButtonObserver(); + } + + public get variant(): SplitButtonVariant { + return this._variant; + } + public set variant(value: SplitButtonVariant) { + if (this._variant !== value) { + this._variant = value ?? DEFAULT_VARIANT; + this._adapter.setVariant(value); + this._adapter.setHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.VARIANT, this._variant); + } + } + + public get disabled(): boolean { + return this._disabled; + } + public set disabled(value: boolean) { + if (this._disabled !== value) { + this._disabled = value; + this._adapter.setDisabled(this._disabled); + this._adapter.toggleHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.DISABLED, this._disabled); + } + } + + public get dense(): boolean { + return this._dense; + } + public set dense(value: boolean) { + if (this._dense !== value) { + this._dense = value; + this._adapter.setDense(this._dense); + this._adapter.toggleHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.DENSE, this._dense); + } + } + + public get pill(): boolean { + return this._pill; + } + public set pill(value: boolean) { + if (this._pill !== value) { + this._pill = value; + this._adapter.setPill(this._pill); + this._adapter.toggleHostAttribute(SPLIT_BUTTON_CONSTANTS.attributes.PILL, this._pill); + } + } +} diff --git a/src/lib/split-button/split-button.html b/src/lib/split-button/split-button.html new file mode 100644 index 000000000..1d343592b --- /dev/null +++ b/src/lib/split-button/split-button.html @@ -0,0 +1,3 @@ + diff --git a/src/lib/split-button/split-button.scss b/src/lib/split-button/split-button.scss new file mode 100644 index 000000000..312d645c0 --- /dev/null +++ b/src/lib/split-button/split-button.scss @@ -0,0 +1,103 @@ +@use './configuration'; +@use './token-utils' as *; +@use '../button'; +@use '../focus-indicator'; + +// +// Host +// + +:host { + @include configuration.configuration; +} + +:host { + display: inline-flex; + justify-content: center; + align-items: center; +} + +:host([hidden]) { + display: none; +} + +// +// Button overrides +// + +::slotted(*) { + @include button.provide-theme(( + min-width: #{token(min-width)}, + focus-indicator-offset: #{token(focus-indicator-offset)} + )); +} + +::slotted(:first-child) { + @include button.provide-theme(( + border-top-right-radius: 0, + border-bottom-right-radius: 0 + )); + + @include focus-indicator.provide-theme(( + shape-start-end: 0, + shape-end-end: 0, + offset-inline: 0 #{token(focus-indicator-offset-adjusted, custom)} + )); +} + +::slotted(:not(:first-child):not(:last-child)) { + @include button.provide-theme(( shape: 0 )); + + @include focus-indicator.provide-theme(( + shape: 0, + offset-inline: #{token(focus-indicator-offset-adjusted, custom)} + )); +} + +::slotted(:last-child) { + @include button.provide-theme(( + border-top-left-radius: 0, + border-bottom-left-radius: 0 + )); + + + @include focus-indicator.provide-theme(( + shape-start-start: 0, + shape-end-start: 0, + offset-inline: #{token(focus-indicator-offset-adjusted, custom)} 0 + )); +} + +// +// Flat & Raised +// + +:host(:is([variant=flat], [variant=raised], :not([variant]))) { + ::slotted(:not(:last-child)) { + margin-inline-end: #{token(gap)}; + } +} + +// +// Outlined +// + +:host([variant=outlined]) { + ::slotted(:not(:first-child)) { + margin-inline-start: calc(-1 * #{token(gap)}); + } + + @include override(focus-indicator-divider-offset, 0px); // Required unit +} + +// +// Disabled +// + +:host(:is([variant=flat], [variant=raised], :not([variant]))[disabled]) { + ::slotted(:not(:last-child)) { + &::after { + @include override(divider-color, disabled-divider-color); + } + } +} diff --git a/src/lib/split-button/split-button.test.ts b/src/lib/split-button/split-button.test.ts new file mode 100644 index 000000000..614180abf --- /dev/null +++ b/src/lib/split-button/split-button.test.ts @@ -0,0 +1,201 @@ +import { expect } from '@esm-bundle/chai'; +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'; + +describe('SplitButton', () => { + it('should initialize', async () => { + const el = await fixture(html``); + expect(el.shadowRoot).not.to.be.null; + }); + + it('should be accessible', async () => { + const el = await fixture(html``); + await expect(el).to.be.accessible(); + }); + + it('should set default state on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => { + expect(button.variant).to.equal(DEFAULT_VARIANT); + expect(button.disabled).to.be.false; + expect(button.dense).to.be.false; + expect(button.pill).to.be.false; + }); + }); + + it('should initialize with new state on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + expect(el.variant).to.equal('outlined'); + expect(el.disabled).to.be.true; + expect(el.dense).to.be.true; + expect(el.pill).to.be.true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => { + expect(button.variant).to.equal('outlined'); + expect(button.disabled).to.be.true; + expect(button.dense).to.be.true; + expect(button.pill).to.be.true; + }); + }); + + it('should update variant on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + el.variant = 'raised'; + + expect(el.hasAttribute(SPLIT_BUTTON_CONSTANTS.attributes.VARIANT)).to.be.true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => expect(button.variant).to.equal('raised')); + }); + + it('should update disabled on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + el.disabled = true; + + expect(el.hasAttribute(SPLIT_BUTTON_CONSTANTS.attributes.DISABLED)).to.be.true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => expect(button.disabled).to.be.true); + }); + + it('should update dense on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + el.dense = true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => expect(button.dense).to.be.true); + }); + + it('should update pill on buttons', async () => { + const el = await fixture(html` + + First + Second + + `); + + el.pill = true; + + expect(el.hasAttribute(SPLIT_BUTTON_CONSTANTS.attributes.PILL)).to.be.true; + + const buttons = el.querySelectorAll('forge-button'); + buttons.forEach(button => expect(button.pill).to.be.true); + }); + + it('should update pill on only first and last buttons', async () => { + const el = await fixture(html` + + First + Middle + Second + + `); + + el.pill = true; + + const buttons = el.querySelectorAll('forge-button'); + expect(buttons[0].pill).to.be.true; + expect(buttons[1].pill).to.be.false; + expect(buttons[2].pill).to.be.true; + }); + + it('should set state on dynamically added buttons', async () => { + const el = await fixture(html` + + First + + `); + + const button1 = document.createElement('forge-button'); + button1.textContent = 'Second'; + el.appendChild(button1); + + const button2 = document.createElement('forge-button'); + button2.textContent = 'Second'; + el.appendChild(button2); + + await elementUpdated(el); + + const buttons = el.querySelectorAll('forge-button'); + expect(buttons.length).to.equal(3); + buttons.forEach(button => { + expect(button.variant).to.equal(el.variant); + expect(button.disabled).to.equal(el.disabled); + expect(button.dense).to.equal(el.dense); + expect(button.pill).to.equal(el.pill); + }); + }); + + it('should update state on dynamically added nested buttons', async () => { + const el = await fixture(html` + + First + + `); + + const menu = document.createElement('forge-menu'); + const button = document.createElement('forge-button'); + button.textContent = 'Second'; + menu.appendChild(button); + el.appendChild(menu); + + await elementUpdated(el); + + expect(button.variant).to.equal('outlined'); + expect(button.disabled).to.true; + expect(button.dense).to.true; + expect(button.pill).to.true; + }); + + it('should not set state on dynamic elements if they do not contain elements', async () => { + const el = await fixture(html` + + First + + `); + + const button = document.createElement('button'); + button.textContent = 'Second'; + el.appendChild(button); + + await elementUpdated(el); + + expect('variant' in button).to.be.false; + expect('pill' in button).to.be.false; + }); +}); diff --git a/src/lib/split-button/split-button.ts b/src/lib/split-button/split-button.ts new file mode 100644 index 000000000..ca830bf24 --- /dev/null +++ b/src/lib/split-button/split-button.ts @@ -0,0 +1,87 @@ +import { attachShadowTemplate, coerceBoolean, CustomElement, FoundationProperty } from '@tylertech/forge-core'; +import { ButtonComponent } 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'; +import { SplitButtonFoundation } from './split-button-foundation'; + +import template from './split-button.html'; +import styles from './split-button.scss'; + +export interface ISplitButtonComponent extends IBaseComponent { + variant: SplitButtonVariant; + disabled: boolean; + dense: boolean; + pill: boolean; +} + +declare global { + interface HTMLElementTagNameMap { + 'forge-split-button': ISplitButtonComponent; + } +} + +/** + * @tag forge-split-button + */ +@CustomElement({ + name: SPLIT_BUTTON_CONSTANTS.elementName, + dependencies: [ + ButtonComponent + ] +}) +export class SplitButtonComponent extends BaseComponent implements ISplitButtonComponent { + public static get observedAttributes(): string[] { + return [ + SPLIT_BUTTON_CONSTANTS.attributes.VARIANT, + SPLIT_BUTTON_CONSTANTS.attributes.DISABLED, + SPLIT_BUTTON_CONSTANTS.attributes.DENSE, + SPLIT_BUTTON_CONSTANTS.attributes.PILL + ]; + } + + private readonly _foundation: SplitButtonFoundation; + + constructor() { + super(); + attachShadowTemplate(this, template, styles); + this._foundation = new SplitButtonFoundation(new SplitButtonAdapter(this)); + } + + public connectedCallback(): void { + this._foundation.initialize(); + } + + public disconnectedCallback(): void { + this._foundation.destroy(); + } + + public attributeChangedCallback(name: string, oldValue: string, newValue: string): void { + switch (name) { + case SPLIT_BUTTON_CONSTANTS.attributes.VARIANT: + this.variant = newValue as SplitButtonVariant; + break; + case SPLIT_BUTTON_CONSTANTS.attributes.DISABLED: + this.disabled = coerceBoolean(newValue); + break; + case SPLIT_BUTTON_CONSTANTS.attributes.DENSE: + this.dense = coerceBoolean(newValue); + break; + case SPLIT_BUTTON_CONSTANTS.attributes.PILL: + this.pill = coerceBoolean(newValue); + break; + } + } + + @FoundationProperty() + public declare variant: SplitButtonVariant; + + @FoundationProperty() + public declare disabled: boolean; + + @FoundationProperty() + public declare dense: boolean; + + @FoundationProperty() + public declare pill: boolean; +}