Skip to content

Commit

Permalink
feat: add vertical tabs
Browse files Browse the repository at this point in the history
  • Loading branch information
v8tenko committed Jun 11, 2024
1 parent 22ab294 commit d686a01
Show file tree
Hide file tree
Showing 8 changed files with 1,281 additions and 21 deletions.
19 changes: 19 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,25 @@ Example:
{% endlist %}
```

Additionally, you can use radiobatons using a contruction

```
{% list tabs vertical %}
- Tab 1
Text 1.
* You can use list
* And **other** features.
- Tab 2
Text 2.
{% endlist %}
```

The keys for the tabs are generated automatically. They are based on the tab's names using the github anchors style.

You can set your own keys for tabs with this statement:
Expand Down
6 changes: 6 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
import type {TabsOrientation} from './plugin/transform';
import type {TabsController} from './runtime/TabsController';

export const TABS_CLASSNAME = 'yfm-tabs';
export const TABS_VERTICAL_CLASSNAME = 'yfm-tabs-vertical';
export const TABS_LIST_CLASSNAME = 'yfm-tab-list';
export const TAB_CLASSNAME = 'yfm-tab';
export const TAB_PANEL_CLASSNAME = 'yfm-tab-panel';
export const ACTIVE_CLASSNAME = 'active';
export const VERTICAL_TAB_CLASSNAME = 'yfm-vertical-tab';

export const GROUP_DATA_KEY = 'data-diplodoc-group';
export const TAB_DATA_KEY = 'data-diplodoc-key';
export const TAB_DATA_ID = 'data-diplodoc-id';
export const TAB_DATA_VERTICAL_TAB = 'data-diplodoc-vertical-tab';
export const TAB_ACTIVE_KEY = 'data-diplodoc-is-active';
export const TAB_RADIO_KEY = 'data-diplodoc-input';

export const DEFAULT_TABS_GROUP_PREFIX = 'defaultTabsGroup-';

export interface Tab {
group?: string;
key: string;
align: TabsOrientation;
}

export interface SelectedTabEvent {
Expand Down
69 changes: 58 additions & 11 deletions src/plugin/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import {
GROUP_DATA_KEY,
TABS_CLASSNAME,
TABS_LIST_CLASSNAME,
TABS_VERTICAL_CLASSNAME,
TAB_ACTIVE_KEY,
TAB_CLASSNAME,
TAB_DATA_ID,
TAB_DATA_KEY,
TAB_DATA_VERTICAL_TAB,
TAB_PANEL_CLASSNAME,
VERTICAL_TAB_CLASSNAME,
} from '../common';

export type PluginOptions = {
Expand All @@ -25,7 +28,9 @@ export type PluginOptions = {
bundle: boolean;
};

const TAB_RE = /`?{% list tabs( group=([^ ]*))? %}`?/;
export type TabsOrientation = 'vertical' | 'horizontal';

const TAB_RE = /`?{% list tabs( group=([^ ]*))?( (vertical)|(horizontal))? %}`?/;

let runsCounter = 0;

Expand Down Expand Up @@ -119,6 +124,7 @@ function findTabs(tokens: Token[], idx: number) {
function insertTabs(
tabs: Tab[],
state: StateCore,
align: TabsOrientation,
{start, end}: {start: number; end: number},
{
containerClasses,
Expand Down Expand Up @@ -155,11 +161,20 @@ function insertTabs(
tabListOpen.block = true;
tabListClose.block = true;

tabsOpen.attrSet('class', [TABS_CLASSNAME, containerClasses].filter(Boolean).join(' '));
const areTabsVerticalClass = align === 'vertical' && TABS_VERTICAL_CLASSNAME;

tabsOpen.attrSet(
'class',
[TABS_CLASSNAME, containerClasses, areTabsVerticalClass].filter(Boolean).join(' '),
);
tabsOpen.attrSet(GROUP_DATA_KEY, tabsGroup);
tabListOpen.attrSet('class', TABS_LIST_CLASSNAME);
tabListOpen.attrSet('role', 'tablist');

if (align === 'vertical') {
tabsTokens.push(tabsOpen);
}

for (let i = 0; i < tabs.length; i++) {
const tabOpen = new state.Token('tab_open', 'div', 1);
const tabInline = new state.Token('inline', '', 0);
Expand All @@ -168,13 +183,24 @@ function insertTabs(
const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1);
const tabPanelClose = new state.Token('tab-panel_close', 'div', -1);

const verticalTabOpen = new state.Token('tab_open', 'input', 0);
const verticalTabLabelOpen = new state.Token('label_open', 'label', 1);

tabOpen.map = tabs[i].listItem.map;
tabOpen.markup = tabs[i].listItem.markup;

const tab = tabs[i];
const tabId = getTabId(tab, {runId});
const tabKey = getTabKey(tab);
tab.name = getName(tab);

const tabPanelId = generateID();

verticalTabOpen.block = true;

verticalTabOpen.attrJoin('class', 'radio');
verticalTabOpen.attrSet('type', 'radio');

tabOpen.map = tabs[i].listItem.map;
tabOpen.markup = tabs[i].listItem.markup;
tabText.content = tabs[i].name;
Expand All @@ -187,6 +213,7 @@ function insertTabs(
tabOpen.attrSet(TAB_DATA_KEY, tabKey);
tabOpen.attrSet(TAB_ACTIVE_KEY, i === 0 ? 'true' : 'false');
tabOpen.attrSet('class', TAB_CLASSNAME);
tabOpen.attrJoin('class', 'yfm-tab-group');
tabOpen.attrSet('role', 'tab');
tabOpen.attrSet('aria-controls', tabPanelId);
tabOpen.attrSet('aria-selected', 'false');
Expand All @@ -197,21 +224,39 @@ function insertTabs(
tabPanelOpen.attrSet('aria-labelledby', tabId);
tabPanelOpen.attrSet('data-title', tab.name);

if (align === 'vertical') {
tabOpen.attrSet(TAB_DATA_VERTICAL_TAB, 'true');
tabOpen.attrJoin('class', VERTICAL_TAB_CLASSNAME);
}

if (i === 0) {
tabOpen.attrJoin('class', ACTIVE_CLASSNAME);
tabOpen.attrSet('aria-selected', 'true');
if (align === 'horizontal') {
tabOpen.attrJoin('class', ACTIVE_CLASSNAME);
tabOpen.attrSet('aria-selected', 'true');
} else {
verticalTabOpen.attrSet('checked', 'true');
}

tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME);
}

tabListTokens.push(tabOpen, tabInline, tabClose);
tabPanelsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose);
if (align === 'vertical') {
tabsTokens.push(tabOpen, verticalTabOpen, verticalTabLabelOpen, tabInline, tabClose);
tabsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose);
} else {
tabListTokens.push(tabOpen, tabInline, tabClose);
tabPanelsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose);
}
}

if (align === 'horizontal') {
tabsTokens.push(tabsOpen);
tabsTokens.push(tabListOpen);
tabsTokens.push(...tabListTokens);
tabsTokens.push(tabListClose);
tabsTokens.push(...tabPanelsTokens);
}

tabsTokens.push(tabsOpen);
tabsTokens.push(tabListOpen);
tabsTokens.push(...tabListTokens);
tabsTokens.push(tabListClose);
tabsTokens.push(...tabPanelsTokens);
tabsTokens.push(tabsClose);

state.tokens.splice(start, end - start + 1, ...tabsTokens);
Expand Down Expand Up @@ -290,13 +335,15 @@ export function transform({
}

const tabsGroup = match[2] || `${DEFAULT_TABS_GROUP_PREFIX}${generateID()}`;
const orientation = (match[4] || 'horizontal') as TabsOrientation;

const {tabs, index} = findTabs(state.tokens, i + 3);

if (tabs.length > 0) {
insertTabs(
tabs,
state,
orientation,
{start: i, end: index + 3},
{
containerClasses,
Expand Down
1 change: 1 addition & 0 deletions src/react/useDiplodocTabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function useDiplodocTabs(callback: UseDiplodocTabsCallback) {
window[GLOBAL_SYMBOL].selectTabById(tabId, options),
[],
),
// @todo remove
selectTab: useCallback((tab: Tab) => window[GLOBAL_SYMBOL].selectTab(tab), []),
};
}
90 changes: 82 additions & 8 deletions src/runtime/TabsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import {
SelectedTabEvent,
TABS_CLASSNAME,
TABS_LIST_CLASSNAME,
TABS_VERTICAL_CLASSNAME,
TAB_CLASSNAME,
TAB_DATA_ID,
TAB_DATA_KEY,
TAB_PANEL_CLASSNAME,
Tab,
} from '../common';
import type {TabsOrientation} from '../plugin/transform';
import {
ElementOffset,
getClosestScrollableParent,
Expand All @@ -24,6 +26,7 @@ const Selector = {
TAB_LIST: `.${TABS_LIST_CLASSNAME}`,
TAB: `.${TAB_CLASSNAME}`,
TAB_PANEL: `.${TAB_PANEL_CLASSNAME}`,
VERTICAL_TABS: `.${TABS_VERTICAL_CLASSNAME}`,
};

export interface ISelectTabByIdOptions {
Expand All @@ -43,12 +46,18 @@ export class TabsController {
this._document = document;
this._document.addEventListener('click', (event) => {
const target = getEventTarget(event) as HTMLElement;
const areVertical = this.areTabsVertical(target);

if (isCustom(event) || !this.isValidTabElement(target)) {
if (isCustom(event)) {
return;
}

if (!(this.isValidTabElement(target) || areVertical)) {
return;
}

const tab = this.getTabDataFromHTMLElement(target);

if (tab) {
this._selectTab(tab, target);
}
Expand Down Expand Up @@ -110,6 +119,7 @@ export class TabsController {
}

const tab = this.getTabDataFromHTMLElement(target);

if (tab) {
this._selectTab(tab, target);
}
Expand All @@ -124,7 +134,7 @@ export class TabsController {
}

private _selectTab(tab: Tab, targetTab?: HTMLElement) {
const {group, key} = tab;
const {group, key, align} = tab;

if (!group) {
return;
Expand All @@ -134,18 +144,65 @@ export class TabsController {
const previousTargetOffset =
scrollableParent && getOffsetByScrollableParent(targetTab, scrollableParent);

const updatedTabs = this.updateHTML({group, key});
const updatedTabs = this.updateHTML({group, key, align}, align);

if (updatedTabs > 0) {
this.fireSelectTabEvent({group, key}, targetTab?.dataset.diplodocId);
this.fireSelectTabEvent({group, key, align}, targetTab?.dataset.diplodocId);

if (previousTargetOffset) {
this.resetScroll(targetTab, scrollableParent, previousTargetOffset);
}
}
}

private updateHTML(tab: Required<Tab>) {
private updateHTML(tab: Required<Tab>, align: TabsOrientation) {
switch (align) {
case 'vertical': {
return this.updateHTMLVertical(tab);
}
case 'horizontal': {
return this.updateHTMLHorizontal(tab);
}
}

return 0;
}

private updateHTMLVertical(tab: Required<Tab>) {
const {group, key} = tab;

const [tabs] = this._document.querySelectorAll(
`${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]`,
);

let updated = 0;
const root = tabs.parentNode!;
const elements = root.children;

for (let i = 0; i < elements.length; i += 2) {
const [title, content] = [elements.item(i), elements.item(i + 1)] as HTMLElement[];

const input = title.children.item(0) as HTMLInputElement;

if (input.hasAttribute('checked')) {
title.classList.remove('active');
content?.classList.remove('active');
input.removeAttribute('checked');
}

if (title === tabs) {
title.classList.add('active');
content?.classList.add('active');
input.setAttribute('checked', 'true');
}

updated++;
}

return updated;
}

private updateHTMLHorizontal(tab: Required<Tab>) {
const {group, key} = tab;

const tabs = this._document.querySelectorAll(
Expand Down Expand Up @@ -205,9 +262,9 @@ export class TabsController {
}

private fireSelectTabEvent(tab: Required<Tab>, diplodocId?: string) {
const {group, key} = tab;
const {group, key, align} = tab;

const eventTab: Tab = group.startsWith(DEFAULT_TABS_GROUP_PREFIX) ? {key} : tab;
const eventTab: Tab = group.startsWith(DEFAULT_TABS_GROUP_PREFIX) ? {key, align} : tab;

this._onSelectTabHandlers.forEach((handler) => {
handler({tab: eventTab, currentTabId: diplodocId});
Expand All @@ -219,13 +276,28 @@ export class TabsController {
element.matches(Selector.TAB) && element.dataset.diplodocId
? element.closest(Selector.TAB_LIST)
: null;

return tabList?.closest(Selector.TABS);
}

private areTabsVertical(target: HTMLElement) {
const parent = target.parentElement;

return target.dataset.diplodocVerticalTab || Boolean(parent?.dataset.diplodocVerticalTab);
}

private getTabDataFromHTMLElement(target: HTMLElement): Tab | null {
if (this.areTabsVertical(target)) {
const tab = target.dataset.diplodocVerticalTab ? target : target.parentElement!;

const key = tab.dataset.diplodocKey;
const group = (tab.closest(Selector.TABS) as HTMLElement)?.dataset.diplodocGroup;
return key && group ? {group, key, align: 'vertical'} : null;
}

const key = target.dataset.diplodocKey;
const group = (target.closest(Selector.TABS) as HTMLElement)?.dataset.diplodocGroup;
return key && group ? {group, key} : null;
return key && group ? {group, key, align: 'horizontal'} : null;
}

private getTabs(target: HTMLElement): {tabs: Tab[]; nodes: NodeListOf<HTMLElement>} {
Expand All @@ -241,9 +313,11 @@ export class TabsController {
return;
}

/** horizontal-only supported feature (used in left/right button click) */
tabs.push({
group,
key,
align: 'horizontal',
});
});

Expand Down
Loading

0 comments on commit d686a01

Please sign in to comment.