Skip to content

Commit

Permalink
feat(button-area): create button area component (#326)
Browse files Browse the repository at this point in the history
  • Loading branch information
samrichardsontylertech authored Oct 12, 2023
1 parent 4b1589f commit da615b4
Show file tree
Hide file tree
Showing 19 changed files with 957 additions and 0 deletions.
46 changes: 46 additions & 0 deletions src/dev/pages/button-area/button-area.ejs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<forge-card outlined>
<forge-button-area id="button-area">
<button slot="button" aria-labelledby="button-area-heading"></button>
<div class="flex-wrapper">
<div>
<div class="forge-typography--headline5" id="button-area-heading">Heading</div>
<div>Content</div>
</div>
<forge-icon-button data-forge-ignore>
<button>
<forge-icon name="favorite"></forge-icon>
</button>
<forge-tooltip>Like</forge-tooltip>
</forge-icon-button>
<forge-icon name="chevron_right"></forge-icon>
</div>
</forge-button-area>
</forge-card>

<br />

<forge-card outlined>
<forge-expansion-panel id="expansion-panel">
<forge-button-area slot="header" id="expansion-panel-button-area">
<button slot="button" id="expansion-panel-button" aria-labelledby="expansion-panel-heading" aria-controls="expansion-panel-content" aria-expanded="false"></button>
<div class="flex-wrapper">
<div>
<div class="forge-typography--headline5" id="expansion-panel-heading">Expansion panel</div>
<div>Subheading</div>
</div>
<forge-icon-button data-forge-ignore>
<button>
<forge-icon name="favorite"></forge-icon>
</button>
<forge-tooltip>Like</forge-tooltip>
</forge-icon-button>
<forge-open-icon></forge-open-icon>
</div>
</forge-button-area>
<div role="group" class="expansion-panel-content" id="expansion-panel-content">
Expansion panel content
</div>
</forge-expansion-panel>
</forge-card>

<script type="module" src="button-area.ts"></script>
11 changes: 11 additions & 0 deletions src/dev/pages/button-area/button-area.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<%-
include('./src/partials/page.ejs', {
page: {
title: 'Button Area',
includePath: './pages/button-area/button-area.ejs',
options: [
{ type: 'switch', label: 'Disabled', id: 'disabled-switch' }
]
}
})
%>
22 changes: 22 additions & 0 deletions src/dev/pages/button-area/button-area.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
forge-card {
--forge-card-padding: 0 !important;
}

.flex-wrapper {
display: flex;
align-items: center;
margin: 16px;

&>*:first-child {
margin-inline-end: auto;
}
}

forge-icon[name=chevron_right],
forge-open-icon {
margin-inline-start: 8px;
}

.expansion-panel-content {
margin: 16px;
}
28 changes: 28 additions & 0 deletions src/dev/pages/button-area/button-area.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { IButtonAreaComponent, IExpansionPanelComponent, ISwitchComponent, IconRegistry } from '@tylertech/forge';
import { tylIconChevronRight, tylIconFavorite } from '@tylertech/tyler-icons/standard';

import '$src/shared';
import '@tylertech/forge/button-area';
import './button-area.scss';

IconRegistry.define([
tylIconChevronRight,
tylIconFavorite
]);

const buttonArea = document.getElementById('button-area') as IButtonAreaComponent;
buttonArea.addEventListener('click', () => alert('Click'));

const expansionPanel = document.getElementById('expansion-panel') as IExpansionPanelComponent;
const expansionPanelButtonArea = document.getElementById('expansion-panel-button-area') as IButtonAreaComponent;
const expansionPanelButton = document.getElementById('expansion-panel-button');
expansionPanel.addEventListener('forge-expansion-panel-toggle', (event: CustomEvent<boolean>) => {
expansionPanelButton.setAttribute('aria-expanded', event.detail.toString());
});


const disabledToggle = document.querySelector('#disabled-switch') as ISwitchComponent;
disabledToggle.addEventListener('forge-switch-select', ({ detail: selected }) => {
buttonArea.disabled = selected;
expansionPanelButtonArea.disabled = selected;
});
1 change: 1 addition & 0 deletions src/dev/src/components.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{ "label": "Bottom sheet", "path": "/pages/bottom-sheet/bottom-sheet.html" },
{ "label": "Busy indicator", "path": "/pages/busy-indicator/busy-indicator.html" },
{ "label": "Button", "path": "/pages/button/button.html" },
{ "label": "Button area", "path": "/pages/button-area/button-area.html" },
{ "label": "Button toggle", "path": "/pages/button-toggle/button-toggle.html", "tags": ["form"] },
{ "label": "Calendar", "path": "/pages/calendar/calendar.html", "tags": ["form"] },
{ "label": "Card", "path": "/pages/card/card.html" },
Expand Down
42 changes: 42 additions & 0 deletions src/lib/button-area/_mixins.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
@use '@material/ripple/ripple' as mdc-ripple;
@use '@material/ripple/ripple-theme' as mdc-ripple-theme;
@use '../utils/mixins' as utils;
@use '../ripple/forge-ripple';

@mixin core-styles() {
.forge-button-area {
@include base;

&:not(.forge-button-area--disabled) {
&.forge-button-area {
@include enabled;
}
}

&__button {
@include button;
}
}
}

@mixin host() {
display: block;
}

@mixin base() {
overflow: hidden;
}

@mixin enabled() {
@include mdc-ripple.surface;
@include mdc-ripple.radius-bounded;
@include mdc-ripple-theme.states(primary);
@include mdc-ripple-theme.states-activated(primary);
@include mdc-ripple-theme.states-selected(primary);

cursor: pointer;
}

@mixin button() {
@include utils.visually-hidden;
}
143 changes: 143 additions & 0 deletions src/lib/button-area/button-area-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { addClass, getShadowElement, removeClass, toggleClass } from '@tylertech/forge-core';

import { BaseAdapter, IBaseAdapter, userInteractionListener } from '../core';
import { ForgeRipple, ForgeRippleAdapter, ForgeRippleCapableSurface, ForgeRippleFoundation } from '../ripple';
import { IButtonAreaComponent } from './button-area';
import { BUTTON_AREA_CONSTANTS } from './button-area-constants';

export interface IButtonAreaAdapter extends IBaseAdapter {
setDisabled(value: boolean): void;
addListener(type: string, listener: (event: Event) => void): void;
removeListener(type: string, listener: (event: Event) => void): void;
addSlotChangeListener(listener: () => void): void;
removeSlotChangeListener(listener: () => void): void;
startButtonObserver(callback: MutationCallback): void;
stopButtonObserver(): void;
detectSlottedButton(): void;
buttonIsDisabled(): boolean;
requestDisabledButtonFrame(): void;
createRipple(): Promise<void>;
}

export class ButtonAreaAdapter extends BaseAdapter<IButtonAreaComponent> implements IButtonAreaAdapter, ForgeRippleCapableSurface {
private _rootElement: HTMLElement;
private _buttonSlotElement: HTMLSlotElement;
private _buttonElement?: HTMLButtonElement;
private _rippleInstance?: ForgeRipple;
private _buttonObserver?: MutationObserver;

constructor(component: IButtonAreaComponent) {
super(component);
this._rootElement = getShadowElement(component, BUTTON_AREA_CONSTANTS.selectors.ROOT);
this._buttonSlotElement = getShadowElement(component, BUTTON_AREA_CONSTANTS.selectors.BUTTON_SLOT) as HTMLSlotElement;
}

public get root(): HTMLElement {
return this._rootElement;
}

public get unbounded(): boolean | undefined {
return false;
}

public get disabled(): boolean | undefined {
return this.buttonIsDisabled();
}

public setDisabled(value: boolean): void {
toggleClass(this._rootElement, value, BUTTON_AREA_CONSTANTS.classes.DISABLED);
this._buttonElement?.toggleAttribute(BUTTON_AREA_CONSTANTS.attributes.DISABLED, value);
}

public addListener(type: string, listener: (event: Event) => void): void {
this._rootElement.addEventListener(type, listener);
}

public removeListener(type: string, listener: (event: Event) => void): void {
this._rootElement.removeEventListener(type, listener);
}

public addSlotChangeListener(listener: () => void): void {
this._buttonSlotElement.addEventListener('slotchange', listener);
}

public removeSlotChangeListener(listener: () => void): void {
this._buttonSlotElement.removeEventListener('slotchange', listener);
}

public startButtonObserver(callback: MutationCallback): void {
if (this._buttonElement) {
this._buttonObserver = new MutationObserver(callback);
this._buttonObserver.observe(this._buttonElement, { attributeFilter: [BUTTON_AREA_CONSTANTS.attributes.DISABLED] });
}
}

public stopButtonObserver(): void {
if (this._buttonObserver) {
this._buttonObserver.disconnect();
this._buttonObserver = undefined;
}
}

public detectSlottedButton(): void {
this._buttonElement = this._buttonSlotElement.assignedElements()[0] as HTMLButtonElement | undefined;
}

public buttonIsDisabled(): boolean {
return this._buttonElement?.disabled ?? true;
}

public requestDisabledButtonFrame(): void {
if (this._buttonElement) {
this._buttonElement.disabled = true;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
requestAnimationFrame(() => this._buttonElement!.disabled = false);
}
}

public async createRipple(): Promise<void> {
if (!this._rippleInstance) {
const type = await this._userInteractionListener();
if (!this._rippleInstance) {
const adapter: ForgeRippleAdapter = {
...ForgeRipple.createAdapter(this),
isSurfaceActive: () => this._rootElement.matches(':active') || (this._buttonElement?.matches(':active') ?? false),
isSurfaceDisabled: () => this.disabled ?? true,
isUnbounded: () => !!this.unbounded,
registerInteractionHandler: (evtType, handler) => {
if (this._isRootEvent(evtType)) {
this._rootElement.addEventListener(evtType, handler, { passive: true });
} else {
this._buttonElement?.addEventListener(evtType, handler, { passive: true});
}
},
deregisterInteractionHandler: (evtType, handler) => {
if (this._isRootEvent(evtType)) {
this._rootElement.removeEventListener(evtType, handler, { passive: true } as AddEventListenerOptions);
} else {
this._buttonElement?.removeEventListener(evtType, handler, { passive: true } as AddEventListenerOptions);
}
},
addClass: (className) => addClass(className, this._rootElement),
removeClass: (className) => removeClass(className, this._rootElement),
updateCssVariable: (varName, value) => this._rootElement.style.setProperty(varName, value)
};
this._rippleInstance = new ForgeRipple(this._rootElement, new ForgeRippleFoundation(adapter));
if (type === 'focusin') {
this._rippleInstance.handleFocus();
}
}
} else {
this._rippleInstance.destroy();
this._rippleInstance = undefined;
}
}

private _userInteractionListener(): ReturnType<typeof userInteractionListener> {
return userInteractionListener(this._rootElement);
}

private _isRootEvent(evtType: string): boolean {
return ['touchstart', 'pointerdown', 'mousedown'].includes(evtType);
}
}
31 changes: 31 additions & 0 deletions src/lib/button-area/button-area-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { COMPONENT_NAME_PREFIX } from '../constants';

const elementName = `${COMPONENT_NAME_PREFIX}button-area`;

const attributes = {
DISABLED: 'disabled',
IGNORE: 'data-forge-ignore',
IGNORE_ALT: 'forge-ignore'
};

const ids = {
ROOT: 'root',
BUTTON_SLOT: 'button'
};

const classes = {
DISABLED: `forge-button-area--disabled`
};

const selectors = {
ROOT: `#${ids.ROOT}`,
BUTTON_SLOT: `slot[name=button]`
};

export const BUTTON_AREA_CONSTANTS = {
elementName,
attributes,
ids,
classes,
selectors
};
Loading

0 comments on commit da615b4

Please sign in to comment.