@hashicorp/design-system-components
Version:
Helios Design System Components
229 lines (226 loc) • 9.34 kB
JavaScript
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { assert, warn } from '@ember/debug';
import { schedule, next } from '@ember/runloop';
import { hash } from '@ember/helper';
import didInsert from '@ember/render-modifiers/modifiers/did-insert';
import didUpdate from '@ember/render-modifiers/modifiers/did-update';
import { HdsTabsSizeValues } from './types.js';
import HdsTabsTab from './tab.js';
import HdsTabsPanel from './panel.js';
import { precompileTemplate } from '@ember/template-compilation';
import { setComponentTemplate } from '@ember/component';
import { g, i } from 'decorator-transforms/runtime';
/**
* Copyright IBM Corp. 2021, 2025
* SPDX-License-Identifier: MPL-2.0
*/
const DEFAULT_SIZE = 'medium';
const SIZES = Object.values(HdsTabsSizeValues);
class HdsTabs extends Component {
static {
g(this.prototype, "_tabNodes", [tracked], function () {
return [];
});
}
#_tabNodes = (i(this, "_tabNodes"), void 0);
static {
g(this.prototype, "_tabIds", [tracked], function () {
return [];
});
}
#_tabIds = (i(this, "_tabIds"), void 0);
static {
g(this.prototype, "_panelNodes", [tracked], function () {
return [];
});
}
#_panelNodes = (i(this, "_panelNodes"), void 0);
static {
g(this.prototype, "_panelIds", [tracked], function () {
return [];
});
}
#_panelIds = (i(this, "_panelIds"), void 0);
static {
g(this.prototype, "_selectedTabIndex", [tracked]);
}
#_selectedTabIndex = (i(this, "_selectedTabIndex"), void 0);
static {
g(this.prototype, "_selectedTabId", [tracked]);
}
#_selectedTabId = (i(this, "_selectedTabId"), void 0);
static {
g(this.prototype, "_isControlled", [tracked]);
}
#_isControlled = (i(this, "_isControlled"), void 0);
get size() {
const {
size = DEFAULT_SIZE
} = this.args;
assert(` for "Hds::Tabs" must be one of the following: ${SIZES.join(', ')}; received: ${size}`, SIZES.includes(size));
return size;
}
constructor(owner, args) {
super(owner, args);
// this is to determine if the "selected" tab logic is controlled in the consumers' code or is maintained as an internal state
this._isControlled = this.args.selectedTabIndex !== undefined;
this._selectedTabIndex = this.args.selectedTabIndex ?? 0;
}
get selectedTabIndex() {
if (this._isControlled) {
return this.args.selectedTabIndex;
} else {
return this._selectedTabIndex;
}
}
set selectedTabIndex(value) {
if (this._isControlled) ; else {
this._selectedTabIndex = value;
}
}
get classNames() {
const classes = ['hds-tabs'];
// add a class based on the @size argument
classes.push(`hds-tabs--size-${this.size}`);
return classes.join(' ');
}
didInsert = () => {
assert('The number of Tabs must be equal to the number of Panels', this._tabNodes.length === this._panelNodes.length);
if (this._selectedTabId) {
this.selectedTabIndex = this._tabIds.indexOf(this._selectedTabId);
}
// eslint-disable-next-line ember/no-runloop
schedule('afterRender', () => {
this.setTabIndicator();
});
};
didUpdateSelectedTabIndex = () => {
// eslint-disable-next-line ember/no-runloop
schedule('afterRender', () => {
this.setTabIndicator();
});
};
didUpdateSelectedTabId = () => {
// if the selected tab is set dynamically (eg. in a `each` loop)
// the `Tab` nodes will be re-inserted/rendered, which means the `this.selectedTabId` variable changes
// but the parent `Tabs` component has already been rendered/inserted but doesn't re-render
// so the value of the `selectedTabIndex` is not updated, unless we trigger a recalculation
// using the `did-update` modifier that checks for changes in the `this.selectedTabId` variable
if (this._selectedTabId) {
this.selectedTabIndex = this._tabIds.indexOf(this._selectedTabId);
}
};
didUpdateParentVisibility = () => {
// eslint-disable-next-line ember/no-runloop
schedule('afterRender', () => {
this.setTabIndicator();
});
};
didInsertTab = (element, isSelected) => {
this._tabNodes = [...this._tabNodes, element];
this._tabIds = [...this._tabIds, element.id];
if (isSelected) {
this._selectedTabId = element.id;
}
};
didUpdateTab = (tabIndex, isSelected) => {
if (isSelected) {
this.selectedTabIndex = tabIndex;
}
this.setTabIndicator();
};
willDestroyTab = element => {
this._tabNodes = this._tabNodes.filter(node => node.id !== element.id);
this._tabIds = this._tabIds.filter(tabId => tabId !== element.id);
};
didInsertPanel = (element, panelId) => {
this._panelNodes = [...this._panelNodes, element];
this._panelIds = [...this._panelIds, panelId];
};
willDestroyPanel = element => {
this._panelNodes = this._panelNodes.filter(node => node.id !== element.id);
this._panelIds = this._panelIds.filter(panelId => panelId !== element.id);
};
onClick = (event, tabIndex) => {
this.selectedTabIndex = tabIndex;
this.setTabIndicator();
// invoke the callback function if it's provided as argument
if (typeof this.args.onClickTab === 'function') {
this.args.onClickTab(event, tabIndex);
}
};
onKeyUp = (tabIndex, event) => {
const leftArrow = 'ArrowLeft';
const rightArrow = 'ArrowRight';
const enterKey = 'Enter';
const spaceKey = ' ';
if (event.key === rightArrow) {
const nextTabIndex = (tabIndex + 1) % this._tabIds.length;
this.focusTab(nextTabIndex, event);
} else if (event.key === leftArrow) {
const prevTabIndex = (tabIndex + this._tabIds.length - 1) % this._tabIds.length;
this.focusTab(prevTabIndex, event);
} else if (event.key === enterKey || event.key === spaceKey) {
this.selectedTabIndex = tabIndex;
}
// scroll selected tab into view (it may be out of view when activated using a keyboard with `prev/next`)
const parentNode = this._tabNodes[this.selectedTabIndex]?.parentNode;
if (parentNode instanceof HTMLElement) {
parentNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest'
});
}
};
// Focus tab for keyboard & mouse navigation:
focusTab = (tabIndex, event) => {
event.preventDefault();
this._tabNodes[tabIndex]?.focus();
};
setTabIndicator = () => {
// eslint-disable-next-line ember/no-runloop
next(() => {
const tabElem = this._tabNodes[this.selectedTabIndex];
if (tabElem != null) {
const tabElemParentNode = tabElem.parentNode;
const tabsElemClosestList = tabElem.closest('.hds-tabs__tablist');
// this condition is `null` if any of the parents has `display: none`
if (tabElemParentNode.offsetParent) {
const tabLeftPos = tabElemParentNode.offsetLeft;
const tabWidth = tabElemParentNode.offsetWidth;
// Set CSS custom properties for indicator
tabsElemClosestList.style.setProperty('--indicator-left-pos', tabLeftPos + 'px');
tabsElemClosestList.style.setProperty('--indicator-width', tabWidth + 'px');
}
} else {
let message = '';
message += '"Hds::Tabs" has tried to set the indicator for an element that doesn\'t exist';
if (this._tabNodes.length === 0) {
message += ' (the array `this._tabNodes` is empty, there are no tabs, probably already destroyed)';
} else {
message += ` (the value ${this.selectedTabIndex} of \`this.selectedTabIndex\` is out of bound for the array \`this._tabNodes\`, whose index range is [0 - ${this._tabNodes.length - 1}])`;
}
// https://api.emberjs.com/ember/5.3/classes/@ember%2Fdebug/methods/warn?anchor=warn
warn(message, true, {
id: 'hds-debug.tabs.setTabIndicator-tabElem-not-available'
});
}
});
};
static {
setComponentTemplate(precompileTemplate("{{!-- template-lint-disable no-invalid-role --}}\n<div class={{this.classNames}} {{didInsert this.didInsert}} {{didUpdate this.didUpdateSelectedTabIndex this.selectedTabIndex}} {{didUpdate this.didUpdateSelectedTabId this._selectedTabId}} {{didUpdate this.didUpdateParentVisibility @isParentVisible}} ...attributes>\n <div class=\"hds-tabs__tablist-wrapper\">\n <ul class=\"hds-tabs__tablist\" role=\"tablist\">\n {{yield (hash Tab=(component HdsTabsTab didInsertNode=this.didInsertTab didUpdateNode=this.didUpdateTab willDestroyNode=this.willDestroyTab tabIds=this._tabIds panelIds=this._panelIds selectedTabIndex=this.selectedTabIndex onClick=this.onClick onKeyUp=this.onKeyUp))}}\n <li class=\"hds-tabs__tab-indicator\" role=\"presentation\"></li>\n </ul>\n </div>\n\n {{yield (hash Panel=(component HdsTabsPanel didInsertNode=this.didInsertPanel willDestroyNode=this.willDestroyPanel tabIds=this._tabIds panelIds=this._panelIds selectedTabIndex=this.selectedTabIndex))}}\n</div>\n{{!-- template-lint-enable no-invalid-role --}}", {
strictMode: true,
scope: () => ({
didInsert,
didUpdate,
hash,
HdsTabsTab,
HdsTabsPanel
})
}), this);
}
}
export { DEFAULT_SIZE, SIZES, HdsTabs as default };
//# sourceMappingURL=index.js.map