veritatisquam
Version:
Carbon charting components, lerna monorepo
491 lines (436 loc) • 14.5 kB
text/typescript
// Internal Imports
import { Component } from '../component';
import { Events, Roles, ToolbarControlTypes } from '../../interfaces';
import { Tools } from '../../tools';
import { DOMUtils } from '../../services';
import * as Configuration from '../../configuration';
// D3 Imports
import { event, select } from 'd3-selection';
// import the settings for the css prefix
import settings from 'carbon-components/es/globals/js/settings';
export class Toolbar extends Component {
type = 'toolbar';
// overflow menu button to control background color
overflowButton: any;
// ul options list element
overflowMenu: any;
// x, y coordinate of overflow menu
overflowMenuX = 0;
overflowMenuY = 0;
// Use a random number to create overflow menu item unique ID
// so they don't interfere the other overflow menu item in a page
overflowMenuItemId = Math.floor(Math.random() * 99999999999);
init() {
const options = this.getOptions();
// Grab the tooltip element
const holder = select(this.services.domUtils.getHolder());
const chartPrefix = Tools.getProperty(options, 'style', 'prefix');
this.overflowMenu = DOMUtils.appendOrSelect(
holder,
`div.${settings.prefix}--${chartPrefix}--overflowMenu`
);
this.overflowMenu.style('max-width', null);
// listen to show overflow menu event to render the overflow menu
this.services.events.addEventListener(
Events.Toolbar.SHOW_OVERFLOW_MENU,
() => {
this.overflowMenu.html(this.getOverflowMenuHTML());
}
);
// listen to hide overflow menu event to hide the overflow menu
this.services.events.addEventListener(
Events.Toolbar.HIDE_OVERFLOW_MENU,
() => {
this.overflowMenu.html(null);
}
);
// hide overflow menu if user clicks on somewhere in web page
document.body.addEventListener('click', () =>
this.updateOverflowMenu(false)
);
}
render(animate = true) {
const isDataLoading = Tools.getProperty(
this.getOptions(),
'data',
'loading'
);
// size of toolbar button with background
const buttonSize = Configuration.toolbar.buttonSize;
const parentY = parseFloat(this.parent.node().getAttribute('y'));
const svg = this.getContainerSVG();
// TODO -- adjust toolbar Y position to align title component
// before layout component supports vertical alignment center
const Y_OFFSET = -6;
svg.attr('transform', `translate(0, ${parentY + Y_OFFSET})`);
const { width } = DOMUtils.getSVGElementSize(
this.services.domUtils.getMainSVG(),
{
useAttrs: true,
}
);
// overflow menu width is 160px
// it's set by Carbon component
const overflowMenuWidth = 160;
// no good solution to get correct Toolbar position
// parent x doesn't work well
// assume the overflow icon has right alignment in layout
this.overflowMenuX = width - overflowMenuWidth;
this.overflowMenuY = parentY + Y_OFFSET + buttonSize;
const container = DOMUtils.appendOrSelect(svg, 'svg.toolbar-container')
.attr('width', '100%')
.attr('height', Configuration.toolbar.height)
.attr('opacity', 1);
// clean children first
container.html(null);
// get the toolbar buttons
const { buttonList, overflowMenuItemList } = this.getControlConfigs();
// overflow button is required only if overflow menu item list is valid
if (!!overflowMenuItemList) {
buttonList.push(this.getOverflowButtonConfig());
}
// loading or empty state
if (isDataLoading || buttonList.length === 0) {
// put an empty rect to keep space unchanged
DOMUtils.appendOrSelect(container, 'svg.toolbar-loading-spacer')
.append('rect')
.attr('height', Configuration.toolbar.height)
.attr('width', buttonSize * 3) // value doesn't matter but can't be empty
.attr('opacity', 0);
} else {
// render toolbar buttons sequentially
let buttonXPosition = 0;
buttonList.forEach((button) => {
// button container
const buttonContainer = DOMUtils.appendOrSelect(
container,
`svg.${button.id}`
).classed('toolbar-button', true);
// add button background rect
const buttonBackground = DOMUtils.appendOrSelect(
buttonContainer,
'rect.toolbar-button-background'
)
.attr('x', buttonXPosition)
.attr('y', 0)
.attr('width', buttonSize)
.attr('height', buttonSize);
const buttonIcon = DOMUtils.appendOrSelect(
buttonContainer,
'svg.toolbar-button-icon'
)
.attr(
'x',
buttonXPosition + Configuration.toolbar.iconPadding
)
.attr('y', Configuration.toolbar.iconPadding)
.attr('width', Configuration.toolbar.iconSize)
.attr('height', Configuration.toolbar.iconSize)
.attr('viewBox', '0 0 32 32')
.attr('role', Roles.IMG);
buttonIcon.html(button.iconSVGContent);
if (button.shouldBeDisabled()) {
buttonContainer
.classed('toolbar-button--disabled', true)
.classed('toolbar-button--focusable', false)
.attr('tabindex', -1)
.attr('role', null);
buttonIcon.classed('toolbar-button-icon--disabled', true);
buttonBackground.classed(
'toolbar-button-background--disabled',
true
);
buttonContainer.on('click', null).on('keyup', null);
} else {
buttonContainer
.classed('toolbar-button--disabled', false)
.classed('toolbar-button--focusable', true)
.attr('tabindex', 0)
.attr('role', Roles.BUTTON);
buttonIcon.classed('toolbar-button-icon--disabled', false);
buttonBackground.classed(
'toolbar-button-background--disabled',
false
);
buttonContainer
.on('click', button.clickFunction)
.on('keyup', () => {
if (
(event.key && event.key === 'Enter') ||
event.key === ' '
) {
event.preventDefault();
button.clickFunction();
}
});
}
buttonXPosition += buttonSize;
});
this.overflowButton = this.getContainerSVG().select(
'svg.toolbar-overflow-menu'
);
if (this.isOverflowMenuOpen()) {
// keep overflow menu displayed
this.updateOverflowMenu(true);
}
}
}
isOverflowMenuOpen() {
return (
this.overflowMenu
.selectAll('ul.bx--overflow-menu-options--open')
.size() > 0
);
}
// show/hide overflow menu
updateOverflowMenu(show: boolean) {
// update overflow button background
if (this.overflowButton) {
this.overflowButton.classed('toolbar-button--hovered', show);
}
if (show) {
this.services.events.dispatchEvent(
Events.Toolbar.SHOW_OVERFLOW_MENU
);
} else {
this.services.events.dispatchEvent(
Events.Toolbar.HIDE_OVERFLOW_MENU
);
}
}
focusOnPreviousEnabledMenuItem(currentItemIndex) {
const overflowMenuItems = this.getOverflowMenuItems();
let previousItemIndex = overflowMenuItems.length;
for (let i = currentItemIndex - 1; i >= 0; i--) {
const previousOverflowMenuItem = overflowMenuItems[i];
if (!previousOverflowMenuItem.shouldBeDisabled()) {
previousItemIndex = i;
break;
}
}
// only if previous enabled menu item found
if (previousItemIndex < overflowMenuItems.length) {
const previousItemNode = select(
'#' +
overflowMenuItems[previousItemIndex].id +
this.overflowMenuItemId
).node();
if ('focus' in previousItemNode) {
previousItemNode.focus();
}
}
}
focusOnNextEnabledMenuItem(currentItemIndex) {
const overflowMenuItems = this.getOverflowMenuItems();
let nextItemIndex = -1;
for (let i = currentItemIndex + 1; i < overflowMenuItems.length; i++) {
const nextOverflowMenuItem = overflowMenuItems[i];
if (!nextOverflowMenuItem.shouldBeDisabled()) {
nextItemIndex = i;
break;
}
}
// only if next enabled menu item found
if (nextItemIndex > -1) {
const nextItemNode = select(
'#' +
overflowMenuItems[nextItemIndex].id +
this.overflowMenuItemId
).node();
if ('focus' in nextItemNode) {
nextItemNode.focus();
}
}
}
toggleOverflowMenu() {
if (this.isOverflowMenuOpen()) {
// hide overflow menu
this.updateOverflowMenu(false);
} else {
// show overflow menu
this.updateOverflowMenu(true);
// setup overflow menu item event listener
const self = this;
const overflowMenuItems = this.getOverflowMenuItems();
overflowMenuItems.forEach((menuItem, index) => {
const element = select(
'#' + menuItem.id + this.overflowMenuItemId
);
if (element !== null) {
element.on('click', () => {
// call the specified function
menuItem.clickFunction();
// hide overflow menu
self.updateOverflowMenu(false);
});
element.on('keyup', () => {
if (event.key === 'Enter') {
// call the specified function
menuItem.clickFunction();
} else if (event.key === 'ArrowUp') {
// focus on previous menu item
self.focusOnPreviousEnabledMenuItem(index);
} else if (event.key === 'ArrowDown') {
// focus on next menu item
self.focusOnNextEnabledMenuItem(index);
}
// Not hide overflow menu by keyboard arrow up/down event
});
}
});
// default to focus on the first enabled menu item
self.focusOnNextEnabledMenuItem(-1);
}
event.stopImmediatePropagation();
}
getOverflowMenuHTML() {
const overflowMenuItems = this.getOverflowMenuItems();
// don't render whole overflow menu if no overflow menu item
if (!overflowMenuItems || overflowMenuItems.length === 0) {
return '';
}
let overflowMenuHtml;
overflowMenuHtml = `<div data-floating-menu-container="true" data-floating-menu-direction="bottom" role="main">
<ul class="bx--overflow-menu-options bx--overflow-menu--flip bx--overflow-menu-options--open"
tabindex="-1" role="${Roles.MENU}" aria-label="Menu" data-floating-menu-direction="bottom"
style="left:${this.overflowMenuX}px; top:${this.overflowMenuY}px;">`;
// generate html for each overflow menu items
overflowMenuItems.forEach((menuItem, index) => {
const menuItemClasses = 'bx--overflow-menu-options__option'.concat(
menuItem.shouldBeDisabled()
? ' bx--overflow-menu-options__option--disabled' // class for disabled menu item
: ''
);
overflowMenuHtml += `<li class="${menuItemClasses}" role="${
Roles.MENU_ITEM
}">
<button class="bx--overflow-menu-options__btn"
data-floating-menu-primary-focus="${index === 0}"
tabindex="-1" index="${index}" title="${menuItem.text}"
id="${menuItem.id + this.overflowMenuItemId}">
<div class="bx--overflow-menu-options__option-content">
${menuItem.text}
</div>
</button>
</li>`;
});
overflowMenuHtml += `</ul></div>`;
return overflowMenuHtml;
}
getControlConfigs() {
const numberOfIcons = Tools.getProperty(
this.getOptions(),
'toolbar',
'numberOfIcons'
);
const controls = Tools.getProperty(
this.getOptions(),
'toolbar',
'controls'
);
const controlList = [];
controls.forEach((control) => {
const controlConfig = this.getControlConfigByType(control.type);
// add to list if config is valid
if (controlConfig) {
controlConfig.text = control.text ? control.text : control.type;
controlList.push(controlConfig);
}
});
if (controlList.length <= numberOfIcons) {
return {
buttonList: controlList,
};
}
return {
// leave one button for overflow button
buttonList: controlList.splice(0, numberOfIcons - 1),
overflowMenuItemList: controlList,
};
}
getOverflowMenuItems() {
const { overflowMenuItemList } = this.getControlConfigs();
if (!!overflowMenuItemList) {
return overflowMenuItemList;
} else {
return [];
}
}
// special button config for overflow button
getOverflowButtonConfig() {
return {
id: 'toolbar-overflow-menu',
shouldBeDisabled: () => false,
iconSVGContent: `<circle cx="16" cy="8" r="2"></circle>
<circle cx="16" cy="16" r="2"></circle>
<circle cx="16" cy="24" r="2"></circle>`,
clickFunction: () => this.toggleOverflowMenu(),
};
}
getControlConfigByType(controlType: ToolbarControlTypes) {
const isZoomBarEnabled =
this.services.zoom.isZoomBarEnabled() &&
!this.services.zoom.isEmptyState();
let controlConfig;
switch (controlType) {
case ToolbarControlTypes.ZOOM_IN:
if (isZoomBarEnabled) {
controlConfig = {
id: 'toolbar-zoomIn',
shouldBeDisabled: () =>
this.services.zoom.isMinZoomDomain(),
iconSVGContent: this.getControlIconByType(controlType),
clickFunction: () => this.services.zoom.zoomIn(),
};
}
break;
case ToolbarControlTypes.ZOOM_OUT:
if (isZoomBarEnabled) {
controlConfig = {
id: 'toolbar-zoomOut',
shouldBeDisabled: () =>
this.services.zoom.isMaxZoomDomain(),
iconSVGContent: this.getControlIconByType(controlType),
clickFunction: () => this.services.zoom.zoomOut(),
};
}
break;
case ToolbarControlTypes.RESET_ZOOM:
if (isZoomBarEnabled) {
controlConfig = {
id: 'toolbar-resetZoom',
shouldBeDisabled: () =>
this.services.zoom.isMaxZoomDomain(),
iconSVGContent: this.getControlIconByType(controlType),
clickFunction: () =>
this.services.zoom.resetZoomDomain(),
};
}
break;
// add more toolbar control configuration here
default:
throw Error(
'Not supported toolbar control type: ' + controlType
);
}
return controlConfig;
}
getControlIconByType(controlType: ToolbarControlTypes) {
switch (controlType) {
case ToolbarControlTypes.ZOOM_IN:
return `<polygon points="19 13 15 13 15 9 13 9 13 13 9 13 9 15 13 15 13 19 15 19 15 15 19 15 19 13"/>
<path d="M22.45,21A10.87,10.87,0,0,0,25,14,11,11,0,1,0,14,25a10.87,10.87,0,0,0,7-2.55L28.59,30,30,28.59ZM14,23a9,9,0,1,1,9-9A9,9,0,0,1,14,23Z"/>`;
case ToolbarControlTypes.ZOOM_OUT:
return `<rect x="9" y="13" width="10" height="2"/>
<path d="M22.45,21A10.87,10.87,0,0,0,25,14,11,11,0,1,0,14,25a10.87,10.87,0,0,0,7-2.55L28.59,30,30,28.59ZM14,23a9,9,0,1,1,9-9A9,9,0,0,1,14,23Z"/>`;
case ToolbarControlTypes.RESET_ZOOM:
return `<path d="M22.4478,21A10.855,10.855,0,0,0,25,14,10.99,10.99,0,0,0,6,6.4658V2H4v8h8V8H7.332a8.9768,8.9768,0,1,1-2.1,8H3.1912A11.0118,11.0118,0,0,0,14,25a10.855,10.855,0,0,0,7-2.5522L28.5859,30,30,28.5859Z"/>`;
// add more icons here
// svg icon must be with 32x32 viewBox
default:
throw Error(
'Not supported toolbar control type: ' + controlType
);
}
}
}