diff --git a/src/common.ts b/src/common.ts index 3c415b4..4488f61 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'; diff --git a/src/plugin/transform.ts b/src/plugin/transform.ts index ad98a26..baf6811 100644 --- a/src/plugin/transform.ts +++ b/src/plugin/transform.ts @@ -19,6 +19,7 @@ import { TAB_DATA_VERTICAL_TAB, TAB_PANEL_CLASSNAME, VERTICAL_TAB_CLASSNAME, + VERTICAL_TAB_FORCED_OPEN, } from '../common'; export type PluginOptions = { @@ -30,7 +31,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; @@ -125,6 +126,7 @@ function insertTabs( tabs: Tab[], state: StateCore, align: TabsOrientation, + initial: number | undefined, {start, end}: {start: number; end: number}, { containerClasses, @@ -227,16 +229,17 @@ function insertTabs( if (align === 'radio') { tabOpen.attrSet(TAB_DATA_VERTICAL_TAB, 'true'); tabOpen.attrJoin('class', VERTICAL_TAB_CLASSNAME); - } - if (i === 0) { - if (align === 'horizontal') { - tabOpen.attrJoin('class', ACTIVE_CLASSNAME); - tabOpen.attrSet('aria-selected', 'true'); - } else { + if (i + 1 === initial) { + tabOpen.attrSet(VERTICAL_TAB_FORCED_OPEN, 'true'); verticalTabOpen.attrSet('checked', 'true'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); } + } + if (i === 0 && align === 'horizontal') { + tabOpen.attrJoin('class', ACTIVE_CLASSNAME); + tabOpen.attrSet('aria-selected', 'true'); tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); } @@ -291,14 +294,53 @@ function matchCloseToken(tokens: Token[], i: number) { ); } +type TabsProps = { + content: string; + orientation: TabsOrientation; + group: string; + initial?: number; +}; + 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) && + matchProps(tokens[i + 1]) ); } +function matchProps(token: Token) { + const {content} = token; + const query = 'list tabs '; + + let index = content.indexOf(query) + query.length; + + let inner = ''; + + while (content[index] !== '%') { + inner += content[index]; + + index++; + } + + const args = inner.split(' '); + const orientation: TabsOrientation = args.includes('radio') ? 'radio' : 'horizontal'; + const group = + args.find((str) => str.startsWith('group'))?.slice('group'.length + 1) || + `${DEFAULT_TABS_GROUP_PREFIX}${generateID()}`; + const initial = args.find((str) => str.startsWith('initial'))?.slice('initial'.length + 1); + + const props: TabsProps = { + content, + orientation, + group, + initial: typeof initial === 'undefined' ? undefined : Number(initial), + }; + + return props; +} + export function transform({ runtimeJsPath = '_assets/tabs-extension.js', runtimeCssPath = '_assets/tabs-extension.css', @@ -318,7 +360,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 +376,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 +384,8 @@ export function transform({ insertTabs( tabs, state, - orientation, + match.orientation, + match.initial, {start: i, end: closeTokenIdx + 2}, { containerClasses, diff --git a/src/runtime/TabsController.ts b/src/runtime/TabsController.ts index f889dd3..79a60a2 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.updateHTMLRadio(tab, target); } case 'horizontal': { return this.updateHTMLHorizontal(tab); @@ -176,13 +181,23 @@ export class TabsController { return 0; } - private updateHTMLVertical(tab: Required) { + private updateHTMLRadio(tab: Required, target: HTMLElement | undefined) { const {group, key} = tab; + const {isForced, root} = this.didTabOpenForced(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 didTabOpenForced(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;