Skip to content

Commit

Permalink
Merge pull request #1688 from hashicorp/selected-tab-condition-proposal
Browse files Browse the repository at this point in the history
`Tabs` - Fix issues for query-params-based status, nested tabs, variable tabs widths
  • Loading branch information
didoo committed Oct 11, 2023
2 parents 7601d9f + 0eada7c commit 36d87a7
Show file tree
Hide file tree
Showing 20 changed files with 1,106 additions and 287 deletions.
10 changes: 10 additions & 0 deletions .changeset/tiny-turkeys-grow.md
@@ -0,0 +1,10 @@
---
"@hashicorp/design-system-components": minor
---

`Tabs` - Refactored logic for `Tabs` component + `Tab/Panel` sub-components to support more complex use cases:

- introduced `@selectedTabIndex` argument to control the "selected" tab from the consuming application, e.g. via query params (effort spearheaded by @MiniHeyd)
- fixed issue with nested tabs not initializing the "selected" indicator correctly
- fixed issue with dynamic tab content not updating the "selected" indicator correctly

9 changes: 7 additions & 2 deletions packages/components/addon/components/hds/tabs/index.hbs
Expand Up @@ -3,17 +3,22 @@
SPDX-License-Identifier: MPL-2.0
}}
{{! template-lint-disable no-invalid-role }}
<div class="hds-tabs" {{did-insert this.didInsert}} ...attributes>
<div
class="hds-tabs"
{{did-insert this.didInsert}}
{{did-update this.updateTabIndicator this.selectedTabIndex @isParentVisible}}
...attributes
>
<div class="hds-tabs__tablist-wrapper">
<ul class="hds-tabs__tablist" role="tablist">
{{yield
(hash
Tab=(component
"hds/tabs/tab"
didInsertNode=this.didInsertTab
didUpdateNode=this.didUpdateTab
willDestroyNode=this.willDestroyTab
tabIds=this.tabIds
panelIds=this.panelIds
selectedTabIndex=this.selectedTabIndex
onClick=this.onClick
onKeyUp=this.onKeyUp
Expand Down
147 changes: 101 additions & 46 deletions packages/components/addon/components/hds/tabs/index.js
Expand Up @@ -7,45 +7,74 @@ import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { assert } from '@ember/debug';
import { schedule } from '@ember/runloop';
import { next, schedule } from '@ember/runloop';

export default class HdsTabsIndexComponent extends Component {
@tracked tabNodes = [];
@tracked tabIds = [];
@tracked panelNodes = [];
@tracked panelIds = [];
@tracked selectedTabIndex;
@tracked _selectedTabIndex = this.args.selectedTabIndex ?? 0;
@tracked selectedTabId;
@tracked isControlled;

@action
didInsert() {
// default starting tab index
let initialTabIndex = 0;
let selectedCount = 0;

this.tabNodes.forEach((tabElement, index) => {
if (tabElement.hasAttribute('data-is-selected')) {
initialTabIndex = index;
selectedCount++;
}
});
this.selectedTabIndex = initialTabIndex;
constructor() {
super(...arguments);

schedule('afterRender', () => {
this.setTabIndicator(initialTabIndex);
});
// 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;
}

get selectedTabIndex() {
if (this.isControlled) {
return this.args.selectedTabIndex;
} else {
return this._selectedTabIndex;
}
}

assert('Only one tab may use isSelected argument', selectedCount <= 1);
set selectedTabIndex(value) {
if (this.isControlled) {
// noop
} else {
this._selectedTabIndex = value;
}
}

@action
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);
}

schedule('afterRender', () => {
this.setTabIndicator();
});
}

@action
didInsertTab(element) {
didInsertTab(element, isSelected) {
this.tabNodes = [...this.tabNodes, element];
this.tabIds = [...this.tabIds, element.id];
if (isSelected) {
if (this.selectedTabId) {
assert('Only one tab may use isSelected argument');
}
this.selectedTabId = element.id;
}
}

@action
didUpdateTab(tabIndex, isSelected) {
if (isSelected) {
this.selectedTabIndex = tabIndex;
}
this.setTabIndicator();
}

@action
Expand All @@ -55,7 +84,7 @@ export default class HdsTabsIndexComponent extends Component {
}

@action
didInsertPanel(panelId, element) {
didInsertPanel(element, panelId) {
this.panelNodes = [...this.panelNodes, element];
this.panelIds = [...this.panelIds, panelId];
}
Expand All @@ -67,40 +96,39 @@ export default class HdsTabsIndexComponent extends Component {
}

@action
onClick(tabIndex, event) {
onClick(event, tabIndex) {
this.selectedTabIndex = tabIndex;
this.setTabIndicator(tabIndex);

// Scroll Tab into view if it's out of view
this.tabNodes[tabIndex].parentNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
this.setTabIndicator();

// invoke the callback function if it's provided as argument
if (typeof this.args.onClickTab === 'function') {
this.args.onClickTab(event);
this.args.onClickTab(event, tabIndex);
}
}

@action
onKeyUp(tabIndex, e) {
onKeyUp(tabIndex, event) {
const leftArrow = 37;
const rightArrow = 39;
const enterKey = 13;
const spaceKey = 32;

if (e.keyCode === rightArrow) {
if (event.keyCode === rightArrow) {
const nextTabIndex = (tabIndex + 1) % this.tabIds.length;
this.focusTab(nextTabIndex, e);
} else if (e.keyCode === leftArrow) {
this.focusTab(nextTabIndex, event);
} else if (event.keyCode === leftArrow) {
const prevTabIndex =
(tabIndex + this.tabIds.length - 1) % this.tabIds.length;
this.focusTab(prevTabIndex, e);
} else if (e.keyCode === enterKey || e.keyCode === spaceKey) {
this.focusTab(prevTabIndex, event);
} else if (event.keyCode === enterKey || event.keyCode === spaceKey) {
this.selectedTabIndex = tabIndex;
}
// scroll selected tab into view (it may be out of view when activated using a keyboard with `prev/next`)
this.tabNodes[this.selectedTabIndex].parentNode.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
}

// Focus tab for keyboard & mouse navigation:
Expand All @@ -114,15 +142,42 @@ export default class HdsTabsIndexComponent extends Component {
this.panelNodes[tabIndex].focus();
}

setTabIndicator(tabIndex) {
const tabElem = this.tabNodes[tabIndex];
const tabsParentElem = tabElem.closest('.hds-tabs');

const tabLeftPos = tabElem.parentNode.offsetLeft;
const tabWidth = tabElem.parentNode.offsetWidth;
setTabIndicator() {
next(() => {
const tabElem = this.tabNodes[this.selectedTabIndex];

if (tabElem) {
const tabsParentElem = tabElem.closest('.hds-tabs__tablist');

// this condition is `null` if any of the parents has `display: none`
if (tabElem.parentNode.offsetParent) {
const tabLeftPos = tabElem.parentNode.offsetLeft;
const tabWidth = tabElem.parentNode.offsetWidth;

// Set CSS custom properties for indicator
tabsParentElem.style.setProperty(
'--indicator-left-pos',
tabLeftPos + 'px'
);
tabsParentElem.style.setProperty(
'--indicator-width',
tabWidth + 'px'
);
}
} else {
assert(
`"Hds::Tabs" has tried to set the indicator for an element that doesn't exist (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
}])`
);
}
});
}

// Set CSS custom properties for indicator
tabsParentElem.style.setProperty('--indicator-left-pos', tabLeftPos + 'px');
tabsParentElem.style.setProperty('--indicator-width', tabWidth + 'px');
@action
updateTabIndicator() {
this.setTabIndicator();
}
}
6 changes: 3 additions & 3 deletions packages/components/addon/components/hds/tabs/panel.hbs
Expand Up @@ -6,11 +6,11 @@
class="hds-tabs__panel"
...attributes
role="tabpanel"
aria-labelledby={{this.tabId}}
id={{this.panelId}}
hidden={{not this.isSelected}}
hidden={{not this.isVisible}}
aria-labelledby={{this.coupledTabId}}
{{did-insert this.didInsertNode}}
{{will-destroy this.willDestroyNode}}
>
{{yield}}
{{yield (hash isVisible=this.isVisible)}}
</section>
29 changes: 18 additions & 11 deletions packages/components/addon/components/hds/tabs/panel.js
Expand Up @@ -10,9 +10,8 @@ import { action } from '@ember/object';

export default class HdsTabsIndexComponent extends Component {
/**
* Generates a unique ID for the Panel
*
* @param panelId
* Generate a unique ID for the Panel
* @return {string}
*/
panelId = 'panel-' + guidFor(this);

Expand All @@ -23,32 +22,40 @@ export default class HdsTabsIndexComponent extends Component {
: undefined;
}

get tabId() {
/**
* Check the condition if the panel is visible (because the coupled/associated tab is selected) or not
* @returns {boolean}
*/
get isVisible() {
return this.nodeIndex === this.args.selectedTabIndex;
}

/**
* Get the ID of the tab coupled/associated with the panel (it's used by the `aria-labelledby` attribute)
* @returns string}
*/
get coupledTabId() {
return this.nodeIndex !== undefined
? this.args.tabIds[this.nodeIndex]
: undefined;
}

get isSelected() {
return this.nodeIndex === this.args.selectedTabIndex;
}

@action
didInsertNode(element) {
let { didInsertNode } = this.args;

if (typeof didInsertNode === 'function') {
this.elementId = element.id;
didInsertNode(this.elementId, ...arguments);
didInsertNode(element, this.elementId);
}
}

@action
willDestroyNode() {
willDestroyNode(element) {
let { willDestroyNode } = this.args;

if (typeof willDestroyNode === 'function') {
willDestroyNode(...arguments);
willDestroyNode(element);
}
}
}
4 changes: 2 additions & 2 deletions packages/components/addon/components/hds/tabs/tab.hbs
Expand Up @@ -11,8 +11,8 @@
id={{this.tabId}}
aria-selected={{if this.isSelected "true" "false"}}
tabindex={{unless this.isSelected "-1"}}
data-is-selected={{this.isInitialTab}}
{{did-insert this.didInsertNode}}
{{did-insert this.didInsertNode @isSelected}}
{{did-update this.didUpdateNode @count @isSelected}}
{{will-destroy this.willDestroyNode}}
{{on "click" this.onClick}}
{{on "keyup" this.onKeyUp}}
Expand Down

2 comments on commit 36d87a7

@vercel
Copy link

@vercel vercel bot commented on 36d87a7 Oct 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

hds-showcase – ./packages/components

hds-showcase-hashicorp.vercel.app
hds-showcase.vercel.app
hds-showcase-git-main-hashicorp.vercel.app
hds-components-hashicorp.vercel.app

@vercel
Copy link

@vercel vercel bot commented on 36d87a7 Oct 11, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.