diff --git a/src/common.ts b/src/common.ts index 3c415b4..2d181fe 100644 --- a/src/common.ts +++ b/src/common.ts @@ -8,6 +8,7 @@ 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 VERTICAL_TAB_FORCED_OPEN = 'data-diplodoc-radio-forced'; export const GROUP_DATA_KEY = 'data-diplodoc-group'; export const TAB_DATA_KEY = 'data-diplodoc-key'; @@ -17,6 +18,7 @@ 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 const ACTIVE_TAB_TEXT = '{selected}'; export interface Tab { group?: string; diff --git a/src/plugin/getTabId.ts b/src/plugin/getTabId.ts index 9f28b07..f91f02d 100644 --- a/src/plugin/getTabId.ts +++ b/src/plugin/getTabId.ts @@ -1,6 +1,7 @@ import GithubSlugger from 'github-slugger'; import {Tab} from './transform'; +import {ACTIVE_TAB_TEXT} from '../common'; const CUSTOM_ID_REGEXP = /\[?{ ?#(\S+) ?}]?/; @@ -8,16 +9,24 @@ const sluggersStorage = new Map(); function parseName(name: string) { const parts = name.match(CUSTOM_ID_REGEXP); - if (!parts) { - return { - name, - customAnchor: null, - }; + let customAnchor: string | null = null; + let pure = name; + + if (parts) { + pure = name.replace(parts[0], ''); + customAnchor = parts[1]; + } else { + pure = name; + customAnchor = null; + } + + if (pure.includes(ACTIVE_TAB_TEXT)) { + pure = pure.replace(ACTIVE_TAB_TEXT, ''); } return { - name: name.replace(parts[0], '').trim(), - customAnchor: parts[1], + name: pure.trim(), + customAnchor, }; } @@ -40,5 +49,7 @@ export function getName(tab: Tab) { } function getRawId(tab: Tab): string { - return parseName(tab.name).customAnchor || tab.name; + const {customAnchor, name} = parseName(tab.name); + + return customAnchor || name; } diff --git a/src/plugin/transform.ts b/src/plugin/transform.ts index ad98a26..bc7c0d6 100644 --- a/src/plugin/transform.ts +++ b/src/plugin/transform.ts @@ -2,11 +2,13 @@ import MarkdownIt from 'markdown-it'; import StateCore from 'markdown-it/lib/rules_core/state_core'; import Token from 'markdown-it/lib/token'; -import {addHiddenProperty, generateID} from './utils'; +import {addHiddenProperty, generateID, trim, unquote} from './utils'; import {copyRuntimeFiles} from './copyRuntimeFiles'; import {getName, getTabId, getTabKey} from './getTabId'; + import { ACTIVE_CLASSNAME, + ACTIVE_TAB_TEXT, DEFAULT_TABS_GROUP_PREFIX, GROUP_DATA_KEY, TABS_CLASSNAME, @@ -19,6 +21,7 @@ import { TAB_DATA_VERTICAL_TAB, TAB_PANEL_CLASSNAME, VERTICAL_TAB_CLASSNAME, + VERTICAL_TAB_FORCED_OPEN, } from '../common'; export type PluginOptions = { @@ -30,7 +33,7 @@ export type PluginOptions = { export type TabsOrientation = 'radio' | 'horizontal'; -const TAB_RE = /`?{% list tabs( group=([^ ]*))?( (radio)|(horizontal))? %}`?/; +const TAB_RE = /`?{% list tabs .*?%}`?/; let runsCounter = 0; @@ -162,6 +165,13 @@ function insertTabs( tabListClose.block = true; const areTabsVerticalClass = align === 'radio' && TABS_VERTICAL_CLASSNAME; + const activeTabsCount = tabs.filter(isTabSelected).length; + + if (activeTabsCount > 1) { + throw new Error('Unable to render tabs with more than 1 active element'); + } + + const hasDefaultOpenTab = activeTabsCount !== 0; tabsOpen.attrSet( 'class', @@ -192,6 +202,12 @@ function insertTabs( const tab = tabs[i]; const tabId = getTabId(tab, {runId}); const tabKey = getTabKey(tab); + const didTabHasActiveAttr = isTabSelected(tab); + /* if user did not provide default open tab we fallback to first tab (in default tabs only) */ + const isTabActive = hasDefaultOpenTab + ? didTabHasActiveAttr + : align === 'horizontal' && i === 0; + tab.name = getName(tab); const tabPanelId = generateID(); @@ -211,13 +227,13 @@ function insertTabs( tabPanelClose.block = true; tabOpen.attrSet(TAB_DATA_ID, tabId); 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'); tabOpen.attrSet('tabindex', i === 0 ? '0' : '-1'); + tabOpen.attrSet(TAB_ACTIVE_KEY, 'false'); tabPanelOpen.attrSet('id', tabPanelId); tabPanelOpen.attrSet('class', TAB_PANEL_CLASSNAME); tabPanelOpen.attrSet('role', 'tabpanel'); @@ -229,15 +245,17 @@ function insertTabs( tabOpen.attrJoin('class', VERTICAL_TAB_CLASSNAME); } - if (i === 0) { - if (align === 'horizontal') { + if (isTabActive) { + if (align === 'radio') { + tabOpen.attrSet(VERTICAL_TAB_FORCED_OPEN, 'true'); + verticalTabOpen.attrSet('checked', 'true'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); + } else { + tabOpen.attrSet(TAB_ACTIVE_KEY, i === 0 ? 'true' : 'false'); tabOpen.attrJoin('class', ACTIVE_CLASSNAME); tabOpen.attrSet('aria-selected', 'true'); - } else { - verticalTabOpen.attrSet('checked', 'true'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); } - - tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); } if (align === 'radio') { @@ -264,6 +282,12 @@ function insertTabs( return tabsTokens.length; } +function isTabSelected(tab: Tab) { + const {name} = tab; + + return name.includes(ACTIVE_TAB_TEXT); +} + function findCloseTokenIdx(tokens: Token[], idx: number) { let level = 0; let i = idx; @@ -291,14 +315,49 @@ function matchCloseToken(tokens: Token[], i: number) { ); } +type TabsProps = { + content: string; + orientation: TabsOrientation; + group: string; +}; + function matchOpenToken(tokens: Token[], i: number) { return ( tokens[i].type === 'paragraph_open' && tokens[i + 1].type === 'inline' && - tokens[i + 1].content.match(TAB_RE) + TAB_RE.test(tokens[i + 1].content) && + props(tokens[i + 1].content) ); } +function props(target: string): TabsProps { + target = trim(target.replace('list tabs', '')); + + const props = target.split(' '); + const result: TabsProps = { + content: target, + orientation: 'horizontal', + group: `${DEFAULT_TABS_GROUP_PREFIX}${generateID()}`, + }; + + for (const prop of props) { + const [key, value] = prop.split('=').map(trim); + + switch (key) { + case 'horizontal': + case 'radio': + result.orientation = key as TabsOrientation; + break; + case 'group': + result.group = unquote(value); + break; + default: + // TODO: lint unknown tabs props + } + } + + return result; +} export function transform({ runtimeJsPath = '_assets/tabs-extension.js', runtimeCssPath = '_assets/tabs-extension.css', @@ -318,7 +377,7 @@ export function transform({ while (i < tokens.length) { const match = matchOpenToken(tokens, i); - const openTag = match && match[0]; + const openTag = match && match.content; const isNotEscaped = openTag && !(openTag.startsWith('`') && openTag.endsWith('`')); if (!match || !isNotEscaped) { @@ -334,8 +393,7 @@ export function transform({ continue; } - const tabsGroup = match[2] || `${DEFAULT_TABS_GROUP_PREFIX}${generateID()}`; - const orientation = (match[4] || 'horizontal') as TabsOrientation; + const tabsGroup = match.group; const {tabs} = findTabs(state.tokens, i + 3); @@ -343,7 +401,7 @@ export function transform({ insertTabs( tabs, state, - orientation, + match.orientation, {start: i, end: closeTokenIdx + 2}, { containerClasses, diff --git a/src/plugin/utils.ts b/src/plugin/utils.ts index 1356e11..bc58bd3 100644 --- a/src/plugin/utils.ts +++ b/src/plugin/utils.ts @@ -3,6 +3,14 @@ export function generateID() { return id.substring(id.length - 8); } +export function trim(target: string) { + return target.trim(); +} + +export function unquote(target: string) { + return target.match(/^(["']).*\1$/) ? target.slice(1, -1) : target; +} + export function addHiddenProperty< B extends Record, F extends string | symbol, diff --git a/src/runtime/TabsController.ts b/src/runtime/TabsController.ts index f889dd3..c517811 100644 --- a/src/runtime/TabsController.ts +++ b/src/runtime/TabsController.ts @@ -11,6 +11,7 @@ import { TAB_DATA_KEY, TAB_PANEL_CLASSNAME, Tab, + VERTICAL_TAB_FORCED_OPEN, } from '../common'; import type {TabsOrientation} from '../plugin/transform'; import { @@ -152,7 +153,7 @@ export class TabsController { const previousTargetOffset = scrollableParent && getOffsetByScrollableParent(targetTab, scrollableParent); - const updatedTabs = this.updateHTML({group, key, align}, align); + const updatedTabs = this.updateHTML({group, key, align}, targetTab, align); if (updatedTabs > 0) { this.fireSelectTabEvent({group, key, align}, targetTab?.dataset.diplodocId); @@ -163,10 +164,14 @@ export class TabsController { } } - private updateHTML(tab: Required, align: TabsOrientation) { + private updateHTML( + tab: Required, + target: HTMLElement | undefined, + align: TabsOrientation, + ) { switch (align) { case 'radio': { - return this.updateHTMLVertical(tab); + return this.updateHTMLVertical(tab, target); } case 'horizontal': { return this.updateHTMLHorizontal(tab); @@ -176,13 +181,23 @@ export class TabsController { return 0; } - private updateHTMLVertical(tab: Required) { + private updateHTMLVertical(tab: Required, target: HTMLElement | undefined) { const {group, key} = tab; + const {isForced, root} = this.didTabOpenForce(target); + + const singleTabSelector = isForced + ? `.yfm-vertical-tab[${VERTICAL_TAB_FORCED_OPEN}="true"]` + : ''; + const tabs = this._document.querySelectorAll( - `${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]`, + `${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]${singleTabSelector}`, ); + if (isForced) { + root?.removeAttribute(VERTICAL_TAB_FORCED_OPEN); + } + let updated = 0; tabs.forEach((tab) => { @@ -194,16 +209,29 @@ export class TabsController { const input = title.children.item(0) as HTMLInputElement; + if (title === tab) { + const checked = input.checked; + + if (checked) { + title.classList.remove('active'); + content?.classList.remove('active'); + + input.removeAttribute('checked'); + } else { + title.classList.add('active'); + content?.classList.add('active'); + + input.setAttribute('checked', 'true'); + } + + continue; + } + if (input.hasAttribute('checked')) { title.classList.remove('active'); content?.classList.remove('active'); - input.removeAttribute('checked'); - } - if (title === tab) { - title.classList.add('active'); - content?.classList.add('active'); - input.setAttribute('checked', 'true'); + input.removeAttribute('checked'); } updated++; @@ -272,6 +300,18 @@ export class TabsController { ); } + private didTabOpenForce(target?: HTMLElement) { + if (!target) { + return {}; + } + + const root = target.dataset.diplodocVerticalTab ? target : target.parentElement; + + const isForced = typeof root?.dataset.diplodocRadioForced !== 'undefined'; + + return {root, isForced}; + } + private fireSelectTabEvent(tab: Required, diplodocId?: string) { const {group, key, align} = tab; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index ff3a0f0..2fbd639 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -87,12 +87,12 @@ describe('plugin', () => { const attrs = [ 'data-diplodoc-id', 'data-diplodoc-key', - 'data-diplodoc-is-active', 'class', 'role', 'aria-controls', 'aria-selected', 'tabindex', + 'data-diplodoc-is-active', ]; // ACT