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;
+}