From a4537c33fd263e7ffb1fdb6031c223ba293828ee Mon Sep 17 00:00:00 2001 From: Gleb Voitenko Date: Thu, 3 Oct 2024 18:18:16 +0300 Subject: [PATCH] feat: support select and accordeon, refactor extension --- package.json | 2 +- src/common.ts | 37 +- src/plugin/find.ts | 188 ++++++++ src/plugin/generate.ts | 15 + src/plugin/transform.ts | 410 +----------------- src/plugin/types.ts | 14 + .../{copyRuntimeFiles.ts => utils/files.ts} | 0 src/plugin/{utils.ts => utils/index.ts} | 17 +- src/plugin/utils/strings.ts | 12 + src/plugin/{getTabId.ts => utils/tabs.ts} | 19 +- src/plugin/variants/accordion.ts | 121 ++++++ src/plugin/variants/dropdown.ts | 145 +++++++ src/plugin/variants/index.ts | 18 + src/plugin/variants/radio.ts | 141 ++++++ src/plugin/variants/regular.ts | 169 ++++++++ src/plugin/variants/types.ts | 23 + src/runtime/TabsController.ts | 222 ++++++++-- src/runtime/scss/tabs.scss | 281 +++++++++--- tests/src/__snapshots__/plugin.test.ts.snap | 2 + 19 files changed, 1320 insertions(+), 516 deletions(-) create mode 100644 src/plugin/find.ts create mode 100644 src/plugin/generate.ts create mode 100644 src/plugin/types.ts rename src/plugin/{copyRuntimeFiles.ts => utils/files.ts} (100%) rename src/plugin/{utils.ts => utils/index.ts} (51%) create mode 100644 src/plugin/utils/strings.ts rename src/plugin/{getTabId.ts => utils/tabs.ts} (71%) create mode 100644 src/plugin/variants/accordion.ts create mode 100644 src/plugin/variants/dropdown.ts create mode 100644 src/plugin/variants/index.ts create mode 100644 src/plugin/variants/radio.ts create mode 100644 src/plugin/variants/regular.ts create mode 100644 src/plugin/variants/types.ts diff --git a/package.json b/package.json index 4d219c2..e35e24d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@diplodoc/tabs-extension", - "version": "3.4.0", + "version": "3.5.0-beta1", "description": "Tabs plugin for Diplodoc transformer and builder", "main": "build/plugin/index.js", "types": "build/plugin/index.d.ts", diff --git a/src/common.ts b/src/common.ts index 2d181fe..7ad78c4 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,29 +1,44 @@ -import type {TabsOrientation} from './plugin/transform'; -import type {TabsController} from './runtime/TabsController'; +import {type TabsController} from './runtime/TabsController'; +export const TAB_RE = /`?{% list tabs .*?%}`?/; 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 TABS_LIST_CLASSNAME = 'yfm-tab-list'; 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 TAB_GROUP_CLASSNAME = 'yfm-tab-group'; +export const TAB_ACTIVE_KEY = 'data-diplodoc-is-active'; export const GROUP_DATA_KEY = 'data-diplodoc-group'; export const TAB_DATA_KEY = 'data-diplodoc-key'; +export const TAB_DATA_VARIANT = 'data-diplodoc-variant'; export const TAB_DATA_ID = 'data-diplodoc-id'; +export const DEFAULT_TABS_GROUP_PREFIX = 'defaultTabsGroup-'; +export const ACTIVE_TAB_TEXT = '{selected}'; +export const TAB_FORCED_OPEN = 'data-diplodoc-forced'; + +export const TABS_DROPDOWN_CLASSNAME = 'yfm-tabs-dropdown'; +export const TABS_DROPDOWN_MENU_CLASSNAME = 'yfm-tabs-dropdown-menu'; +export const TABS_DROPDOWN_SELECT = 'yfm-tabs-dropdown-select'; + +export const TABS_ACCORDION_CLASSNAME = 'yfm-tabs-accordion'; +export const TABS_ACCORDION_CLIENT_HEIGHT = 'data-yfm-tabs-accordion-client-heights'; + +export const TABS_RADIO_CLASSNAME = 'yfm-tabs-vertical'; +export const VERTICAL_TAB_CLASSNAME = 'yfm-vertical-tab'; 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 const ACTIVE_TAB_TEXT = '{selected}'; +export enum TabsVariants { + Regular = 'regular', + Radio = 'radio', + Dropdown = 'dropdown', + Accordion = 'accordion', +} export interface Tab { group?: string; key: string; - align: TabsOrientation; + variant: TabsVariants; } export interface SelectedTabEvent { diff --git a/src/plugin/find.ts b/src/plugin/find.ts new file mode 100644 index 0000000..2e189ac --- /dev/null +++ b/src/plugin/find.ts @@ -0,0 +1,188 @@ +import Token from 'markdown-it/lib/token'; + +import {DEFAULT_TABS_GROUP_PREFIX, TAB_RE, TabsVariants} from '../common'; + +import {RuntimeTab, TabsProps} from './types'; +import {generateID, trim, unquote} from './utils'; + +function findCloseTokenIndex(tokens: Token[], idx: number) { + let level = 0; + let i = idx; + while (i < tokens.length) { + if (matchOpenToken(tokens, i)) { + level++; + } else if (matchCloseToken(tokens, i)) { + if (level === 0) { + return i; + } + level--; + } + + i++; + } + + return null; +} + +function matchCloseToken(tokens: Token[], i: number) { + return ( + tokens[i].type === 'paragraph_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 1].content.trim() === '{% endlist %}' + ); +} + +function matchOpenToken(tokens: Token[], i: number) { + return ( + tokens[i].type === 'paragraph_open' && + tokens[i + 1].type === 'inline' && + tokens[i + 1].content.match(TAB_RE) + ); +} + +export function props(content: string): TabsProps { + const clean = trim(content.replace('list tabs', '')); + + const props = clean.split(' '); + const result: TabsProps = { + content: clean, + orientation: TabsVariants.Regular, + group: `${DEFAULT_TABS_GROUP_PREFIX}${generateID()}`, + }; + + for (const prop of props) { + const [key, value] = prop.split('=').map(trim); + + switch (key) { + case 'horizontal': + case 'radio': + case 'dropdown': + case 'accordion': + result.orientation = key as TabsVariants; + break; + case 'group': + result.group = unquote(value); + break; + default: + // TODO: lint unknown tabs props + } + } + + return result; +} + +type Result = + | { + step: number; + } + | { + content: string; + closeTokenIndex: number; + }; + +export function tryToFindTabs(tokens: Token[], index: number): Result { + const match = matchOpenToken(tokens, index); + const openTag = match && match[0]; + const isNotEscaped = openTag && !(openTag.startsWith('`') && openTag.endsWith('`')); + + if (!match || !isNotEscaped) { + return { + step: 1, + }; + } + + const closeTokenIndex = findCloseTokenIndex(tokens, index + 3); + + if (!closeTokenIndex) { + tokens[index].attrSet('YFM005', 'true'); + + return { + step: 3, + }; + } + + return { + content: openTag, + closeTokenIndex, + }; +} + +export function findTabs(tokens: Token[], idx: number, closeTokenIdx: number) { + const tabs = []; + let i = idx; + let nestedLevel = -1; + let pending: RuntimeTab = { + name: '', + tokens: [], + listItem: new Token('list_item_open', '', 0), + }; + + while (i < tokens.length) { + const token = tokens[i]; + + switch (token.type) { + case 'ordered_list_open': + case 'bullet_list_open': + if (nestedLevel > -1) { + pending.tokens.push(token); + } + + nestedLevel++; + + break; + case 'list_item_open': + if (nestedLevel) { + pending.tokens.push(token); + } else { + pending = {name: '', tokens: [], listItem: token}; + } + + break; + case 'list_item_close': + if (nestedLevel) { + pending.tokens.push(token); + } else { + tabs.push(pending); + } + + break; + case 'ordered_list_close': + case 'bullet_list_close': + if (!nestedLevel) { + return tabs; + } + + nestedLevel--; + + pending.tokens.push(token); + + break; + case 'paragraph_open': + if ( + i === closeTokenIdx && + tokens[i + 1].content && + tokens[i + 1].content.trim() === '{% endlist %}' + ) { + if (pending && !nestedLevel) { + tabs.push(pending); + } + + return tabs; + } + + if (!pending.name && tokens[i + 1].type === 'inline') { + pending.name = tokens[i + 1].content; + + i += 2; + } else { + pending.tokens.push(token); + } + break; + default: + pending.tokens.push(token); + } + i++; + } + + return tabs; +} diff --git a/src/plugin/generate.ts b/src/plugin/generate.ts new file mode 100644 index 0000000..194fdc3 --- /dev/null +++ b/src/plugin/generate.ts @@ -0,0 +1,15 @@ +import type StateCore from 'markdown-it/lib/rules_core/state_core'; + +import {RuntimeTab} from './types'; +import {generateTokensByType} from './variants'; +import {TabsGenerationProps} from './variants/types'; + +export function generateTabsTokens( + tabs: RuntimeTab[], + state: StateCore, + props: TabsGenerationProps, +) { + const tokens = generateTokensByType(props.orientation)(tabs, state, props); + + return tokens; +} diff --git a/src/plugin/transform.ts b/src/plugin/transform.ts index 536de4c..73f41a5 100644 --- a/src/plugin/transform.ts +++ b/src/plugin/transform.ts @@ -1,28 +1,9 @@ -import MarkdownIt from 'markdown-it'; -import StateCore from 'markdown-it/lib/rules_core/state_core'; -import Token from 'markdown-it/lib/token'; +import type StateCore from 'markdown-it/lib/rules_core/state_core'; +import type MarkdownIt from 'markdown-it'; -import { - ACTIVE_CLASSNAME, - ACTIVE_TAB_TEXT, - DEFAULT_TABS_GROUP_PREFIX, - 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, - VERTICAL_TAB_FORCED_OPEN, -} from '../common'; - -import {addHiddenProperty, generateID, trim, unquote} from './utils'; -import {copyRuntimeFiles} from './copyRuntimeFiles'; -import {getName, getTabId, getTabKey} from './getTabId'; +import {addHiddenProperty, copyRuntimeFiles} from './utils'; +import {generateTabsTokens} from './generate'; +import {findTabs, props, tryToFindTabs} from './find'; export type PluginOptions = { runtimeJsPath: string; @@ -31,350 +12,12 @@ export type PluginOptions = { bundle: boolean; }; -export type TabsOrientation = 'radio' | 'horizontal'; - -const TAB_RE = /`?{% list tabs .*?%}`?/; - let runsCounter = 0; -export type Tab = { - name: string; - tokens: Token[]; - listItem: Token; -}; - type TransformOptions = { output?: string; }; -function findTabs(tokens: Token[], idx: number, closeTokenIdx: number) { - const tabs = []; - let i = idx, - nestedLevel = -1, - pending: Tab = {name: '', tokens: [], listItem: new Token('list_item_open', '', 0)}; - - while (i < tokens.length) { - const token = tokens[i]; - - switch (token.type) { - case 'ordered_list_open': - case 'bullet_list_open': - if (nestedLevel > -1) { - pending.tokens.push(token); - } - nestedLevel++; - break; - case 'list_item_open': - if (nestedLevel) { - pending.tokens.push(token); - } else { - pending = {name: '', tokens: [], listItem: token}; - } - break; - case 'list_item_close': - if (nestedLevel) { - pending.tokens.push(token); - } else { - tabs.push(pending); - } - break; - case 'ordered_list_close': - case 'bullet_list_close': - if (!nestedLevel) { - return { - tabs, - index: i, - }; - } - - nestedLevel--; - - pending.tokens.push(token); - - break; - case 'paragraph_open': - if ( - i === closeTokenIdx && - tokens[i + 1].content && - tokens[i + 1].content.trim() === '{% endlist %}' - ) { - if (pending && !nestedLevel) { - tabs.push(pending); - } - - return { - tabs, - index: i + 2, - }; - } - - if (!pending.name && tokens[i + 1].type === 'inline') { - pending.name = tokens[i + 1].content; - i += 2; - } else { - pending.tokens.push(token); - } - break; - default: - pending.tokens.push(token); - } - - i++; - } - - return { - tabs, - index: i, - }; -} - -function insertTabs( - tabs: Tab[], - state: StateCore, - align: TabsOrientation, - {start, end}: {start: number; end: number}, - { - containerClasses, - tabsGroup, - runId, - }: {containerClasses: string; tabsGroup: string; runId: string}, -) { - const tabsTokens = []; - const tabListTokens = []; - const tabPanelsTokens = []; - const tabsOpen = new state.Token('tabs_open', 'div', 1); - const tabsClose = new state.Token('tabs_close', 'div', -1); - const tabListOpen = new state.Token('tab-list_open', 'div', 1); - const tabListClose = new state.Token('tab-list_close', 'div', -1); - - if (tabs.length) { - const [start] = tabs[0].listItem.map ?? [null]; - // eslint-disable-next-line no-eq-null, eqeqeq - if (start == null) { - throw new Error('failed to parse line mapping'); - } - - const [_, end] = tabs[tabs.length - 1].listItem.map ?? [null, null]; - // eslint-disable-next-line no-eq-null, eqeqeq - if (end == null) { - throw new Error('failed to parse line mapping'); - } - - tabListOpen.map = [start, end]; - } - - tabsOpen.block = true; - tabsClose.block = true; - tabListOpen.block = true; - 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', - [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 === 'radio') { - 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); - const tabText = new state.Token('text', '', 0); - const tabClose = new state.Token('tab_close', 'div', -1); - const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1); - const tabPanelClose = new state.Token('tab-panel_close', 'div', -1); - - const verticalTabInput = new state.Token('tab-input', 'input', 0); - const verticalTabLabelOpen = new state.Token('tab-label_open', 'label', 1); - const verticalTabLabelClose = new state.Token('tab-label_close', '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); - 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(); - - verticalTabInput.block = true; - - verticalTabInput.attrJoin('class', 'radio'); - verticalTabInput.attrSet('type', 'radio'); - - tabOpen.map = tabs[i].listItem.map; - tabOpen.markup = tabs[i].listItem.markup; - tabText.content = tabs[i].name; - tabInline.children = [tabText]; - tabOpen.block = true; - tabClose.block = true; - tabPanelOpen.block = true; - tabPanelClose.block = true; - tabOpen.attrSet(TAB_DATA_ID, tabId); - tabOpen.attrSet(TAB_DATA_KEY, tabKey); - 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'); - tabPanelOpen.attrSet('aria-labelledby', tabId); - tabPanelOpen.attrSet('data-title', tab.name); - - if (align === 'radio') { - tabOpen.attrSet(TAB_DATA_VERTICAL_TAB, 'true'); - tabOpen.attrJoin('class', VERTICAL_TAB_CLASSNAME); - } - - if (isTabActive) { - if (align === 'radio') { - tabOpen.attrSet(VERTICAL_TAB_FORCED_OPEN, 'true'); - verticalTabInput.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'); - tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); - } - } - - if (align === 'radio') { - tabsTokens.push( - tabOpen, - verticalTabInput, - verticalTabLabelOpen, - tabInline, - verticalTabLabelClose, - 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(tabsClose); - - state.tokens.splice(start, end - start + 1, ...tabsTokens); - - 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; - while (i < tokens.length) { - if (matchOpenToken(tokens, i)) { - level++; - } else if (matchCloseToken(tokens, i)) { - if (level === 0) { - return i; - } - level--; - } - - i++; - } - - return null; -} - -function matchCloseToken(tokens: Token[], i: number) { - return ( - tokens[i].type === 'paragraph_open' && - tokens[i + 1].type === 'inline' && - tokens[i + 1].content.trim() === '{% endlist %}' - ); -} - -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' && - props(tokens[i + 1].content) - ); -} - -function props(target: string): TabsProps | undefined { - const matched = target.match(TAB_RE); - - if (!matched) { - return undefined; - } - - const clean = trim(matched[0].replace('list tabs', '')); - - const props = clean.split(' '); - const result: TabsProps = { - content: clean, - 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', @@ -393,43 +36,34 @@ export function transform({ let tabsAreInserted = false; while (i < tokens.length) { - const match = matchOpenToken(tokens, i); - const openTag = match && match.content; - const isNotEscaped = openTag && !(openTag.startsWith('`') && openTag.endsWith('`')); + const result = tryToFindTabs(tokens, i); - if (!match || !isNotEscaped) { - i++; - continue; - } - - const closeTokenIdx = findCloseTokenIdx(tokens, i + 3); + if ('step' in result) { + i += result.step; - if (!closeTokenIdx) { - tokens[i].attrSet('YFM005', 'true'); - i += 3; continue; } - const tabsGroup = match.group; + const {content, closeTokenIndex} = result; + + const {group, orientation} = props(content); - const {tabs} = findTabs(state.tokens, i + 3, closeTokenIdx); + const tabs = findTabs(state.tokens, i + 3, closeTokenIndex); if (tabs.length > 0) { - insertTabs( - tabs, - state, - match.orientation, - {start: i, end: closeTokenIdx + 2}, - { - containerClasses, - tabsGroup, - runId, - }, - ); + const tabsTokens = generateTabsTokens(tabs, state, { + containerClasses, + tabsGroup: group, + orientation, + runId, + }); + + state.tokens.splice(i, closeTokenIndex - i + 3, ...tabsTokens); + i++; tabsAreInserted = true; } else { - state.tokens.splice(i, closeTokenIdx - i + 3); + state.tokens.splice(i, closeTokenIndex - i + 3); } } diff --git a/src/plugin/types.ts b/src/plugin/types.ts new file mode 100644 index 0000000..1e7a139 --- /dev/null +++ b/src/plugin/types.ts @@ -0,0 +1,14 @@ +import type Token from 'markdown-it/lib/token'; +import type {TabsVariants} from '../common'; + +export type RuntimeTab = { + name: string; + tokens: Token[]; + listItem: Token; +}; + +export type TabsProps = { + content: string; + orientation: TabsVariants; + group: string; +}; diff --git a/src/plugin/copyRuntimeFiles.ts b/src/plugin/utils/files.ts similarity index 100% rename from src/plugin/copyRuntimeFiles.ts rename to src/plugin/utils/files.ts diff --git a/src/plugin/utils.ts b/src/plugin/utils/index.ts similarity index 51% rename from src/plugin/utils.ts rename to src/plugin/utils/index.ts index bc58bd3..874ad49 100644 --- a/src/plugin/utils.ts +++ b/src/plugin/utils/index.ts @@ -1,16 +1,3 @@ -export function generateID() { - const id = Math.random().toString(36).substr(2, 8); - 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, @@ -25,3 +12,7 @@ export function addHiddenProperty< return box as B & {[P in F]: V}; } + +export * from './strings'; +export * from './tabs'; +export * from './files'; diff --git a/src/plugin/utils/strings.ts b/src/plugin/utils/strings.ts new file mode 100644 index 0000000..3e638f5 --- /dev/null +++ b/src/plugin/utils/strings.ts @@ -0,0 +1,12 @@ +export function generateID() { + const id = Math.random().toString(36).substr(2, 8); + 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; +} diff --git a/src/plugin/getTabId.ts b/src/plugin/utils/tabs.ts similarity index 71% rename from src/plugin/getTabId.ts rename to src/plugin/utils/tabs.ts index f094ca9..8f7fb82 100644 --- a/src/plugin/getTabId.ts +++ b/src/plugin/utils/tabs.ts @@ -1,8 +1,7 @@ import GithubSlugger from 'github-slugger'; -import {ACTIVE_TAB_TEXT} from '../common'; - -import {Tab} from './transform'; +import {ACTIVE_TAB_TEXT} from '../../common'; +import {RuntimeTab} from '../types'; const CUSTOM_ID_REGEXP = /\[?{ ?#(\S+) ?}]?/; @@ -31,7 +30,7 @@ function parseName(name: string) { }; } -export function getTabId(tab: Tab, {runId}: {runId: string}) { +export function getTabId(tab: RuntimeTab, {runId}: {runId: string}) { let slugger = sluggersStorage.get(runId); if (!slugger) { slugger = new GithubSlugger(); @@ -41,15 +40,21 @@ export function getTabId(tab: Tab, {runId}: {runId: string}) { return slugger.slug(getRawId(tab)); } -export function getTabKey(tab: Tab) { +export function isTabSelected(tab: RuntimeTab) { + const {name} = tab; + + return name.includes(ACTIVE_TAB_TEXT); +} + +export function getTabKey(tab: RuntimeTab) { return encodeURIComponent(getRawId(tab)).toLocaleLowerCase(); } -export function getName(tab: Tab) { +export function getName(tab: RuntimeTab) { return parseName(tab.name).name; } -function getRawId(tab: Tab): string { +function getRawId(tab: RuntimeTab): string { const {customAnchor, name} = parseName(tab.name); return customAnchor || name; diff --git a/src/plugin/variants/accordion.ts b/src/plugin/variants/accordion.ts new file mode 100644 index 0000000..f349085 --- /dev/null +++ b/src/plugin/variants/accordion.ts @@ -0,0 +1,121 @@ +import type StateCore from 'markdown-it/lib/rules_core/state_core'; + +import { + ACTIVE_CLASSNAME, + GROUP_DATA_KEY, + TABS_ACCORDION_CLASSNAME, + TABS_CLASSNAME, + TAB_ACTIVE_KEY, + TAB_CLASSNAME, + TAB_DATA_ID, + TAB_DATA_KEY, + TAB_DATA_VARIANT, + TAB_FORCED_OPEN, + TAB_GROUP_CLASSNAME, + TAB_PANEL_CLASSNAME, + TabsVariants, +} from '../../common'; +import {generateID, getName, getTabId, getTabKey, isTabSelected} from '../utils'; +import {type RuntimeTab} from '../types'; + +import {type TabsTokensGenerator} from './types'; + +export const accordion: TabsTokensGenerator = ( + tabs: RuntimeTab[], + state: StateCore, + {containerClasses, tabsGroup, runId}, +) => { + const tabsTokens = []; + const tabsOpen = new state.Token('tabs_open', 'div', 1); + const tabsClose = new state.Token('tabs_close', 'div', -1); + + if (tabs.length) { + const [start] = tabs[0].listItem.map ?? [null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (start == null) { + throw new Error('failed to parse line mapping'); + } + + const [_, end] = tabs[tabs.length - 1].listItem.map ?? [null, null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (end == null) { + throw new Error('failed to parse line mapping'); + } + } + + tabsOpen.block = true; + tabsClose.block = true; + + const activeTabsCount = tabs.filter(isTabSelected).length; + + if (activeTabsCount > 1) { + throw new Error('Unable to render tabs with more than 1 active element'); + } + + tabsOpen.attrSet( + 'class', + [TABS_CLASSNAME, containerClasses, TABS_ACCORDION_CLASSNAME].filter(Boolean).join(' '), + ); + + tabsOpen.attrSet(GROUP_DATA_KEY, tabsGroup); + tabsOpen.attrSet(TAB_DATA_VARIANT, TabsVariants.Accordion); + 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); + const tabText = new state.Token('text', '', 0); + const tabClose = new state.Token('tab_close', 'div', -1); + const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1); + const tabPanelClose = new state.Token('tab-panel_close', 'div', -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); + const didTabHasActiveAttr = isTabSelected(tab); + + tab.name = getName(tab); + + const tabPanelId = generateID(); + + tabOpen.map = tabs[i].listItem.map; + tabOpen.markup = tabs[i].listItem.markup; + tabText.content = tabs[i].name; + tabInline.children = [tabText]; + tabOpen.block = true; + tabClose.block = true; + tabPanelOpen.block = true; + tabPanelClose.block = true; + tabOpen.attrSet(TAB_DATA_ID, tabId); + tabOpen.attrSet(TAB_DATA_KEY, tabKey); + tabOpen.attrSet('class', [TAB_CLASSNAME, TAB_GROUP_CLASSNAME].join(' ')); + 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'); + tabPanelOpen.attrSet('aria-labelledby', tabId); + tabPanelOpen.attrSet('data-title', tab.name); + + if (didTabHasActiveAttr) { + tabPanelOpen.attrSet(TAB_FORCED_OPEN, 'true'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); + + tabOpen.attrJoin('class', ACTIVE_CLASSNAME); + } + + tabsTokens.push(tabOpen, tabInline, tabClose); + + tabsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose); + } + + tabsTokens.push(tabsClose); + + return tabsTokens; +}; diff --git a/src/plugin/variants/dropdown.ts b/src/plugin/variants/dropdown.ts new file mode 100644 index 0000000..6c9f6cb --- /dev/null +++ b/src/plugin/variants/dropdown.ts @@ -0,0 +1,145 @@ +import type StateCore from 'markdown-it/lib/rules_core/state_core'; + +import { + ACTIVE_CLASSNAME, + GROUP_DATA_KEY, + TABS_CLASSNAME, + TABS_DROPDOWN_CLASSNAME, + TABS_DROPDOWN_MENU_CLASSNAME, + TABS_DROPDOWN_SELECT, + TAB_CLASSNAME, + TAB_DATA_ID, + TAB_DATA_KEY, + TAB_DATA_VARIANT, + TAB_PANEL_CLASSNAME, +} from '../../common'; +import {generateID, getName, getTabId, getTabKey, isTabSelected} from '../utils'; +import {type RuntimeTab} from '../types'; + +import {type TabsTokensGenerator} from './types'; + +export const dropdown: TabsTokensGenerator = ( + tabs: RuntimeTab[], + state: StateCore, + {containerClasses, tabsGroup, runId}, +) => { + const dropdownTokens = []; + const dropdownOpen = new state.Token('dropdown_open', 'div', 1); + const dropdownClose = new state.Token('dropdown_close', 'div', -1); + const dropdownSelectOpen = new state.Token('dropdown-select_open', 'div', 1); + const dropdownSelectInline = new state.Token('inline', '', 0); + const dropdownSelectText = new state.Token('text', '', 0); + const dropdownSelectClose = new state.Token('dropdown-select_open', 'div', -1); + + if (tabs.length) { + const [start] = tabs[0].listItem.map ?? [null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (start == null) { + throw new Error('failed to parse line mapping'); + } + + const [_, end] = tabs[tabs.length - 1].listItem.map ?? [null, null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (end == null) { + throw new Error('failed to parse line mapping'); + } + } + + dropdownOpen.block = true; + dropdownClose.block = true; + dropdownSelectOpen.block = true; + dropdownSelectClose.block = true; + + const [activeTab, ...restActiveTabs] = tabs.filter(isTabSelected); + + if (restActiveTabs.length) { + throw new Error('Unable to dropdown tabs with more than 1 active element'); + } + + dropdownOpen.attrSet( + 'class', + [TABS_CLASSNAME, containerClasses, TABS_DROPDOWN_CLASSNAME].filter(Boolean).join(' '), + ); + + dropdownOpen.attrSet(GROUP_DATA_KEY, tabsGroup); + dropdownOpen.attrSet(TAB_DATA_VARIANT, 'dropdown'); + + dropdownSelectOpen.attrSet('role', 'tablist'); + dropdownSelectOpen.attrSet('class', TABS_DROPDOWN_SELECT); + + if (activeTab) { + dropdownSelectOpen.attrJoin('class', 'filled'); + } + + dropdownSelectText.content = activeTab ? activeTab.name : '-'; + dropdownSelectInline.children = [dropdownSelectText]; + + dropdownTokens.push( + dropdownOpen, + dropdownSelectOpen, + dropdownSelectInline, + dropdownSelectClose, + ); + + const dropdownMenuOpen = new state.Token('dropdown-menu_open', 'ul', 1); + const dropdownMenuClose = new state.Token('dropdown-menu_close', 'ul', -1); + + dropdownMenuOpen.attrSet('class', TABS_DROPDOWN_MENU_CLASSNAME); + + const menuTokens = tabs.flatMap((tab) => { + const menuListItemOpen = new state.Token('dropdown-menu-item_open', 'li', 1); + const menuListItemClose = new state.Token('dropdown-menu-item_close', 'li', -1); + const menuListItemText = new state.Token('text', '', 0); + const menuListItemInline = new state.Token('inline', '', 0); + + const tabId = getTabId(tab, {runId}); + const tabKey = getTabKey(tab); + const isActive = tab === activeTab; + + menuListItemOpen.attrSet( + 'class', + [TAB_CLASSNAME, isActive && ACTIVE_CLASSNAME].filter(Boolean).join(' '), + ); + menuListItemOpen.attrSet(TAB_DATA_ID, tabId); + menuListItemOpen.attrSet(TAB_DATA_KEY, tabKey); + menuListItemOpen.attrSet('aria-selected', String(isActive)); + + menuListItemText.content = tab.name; + menuListItemInline.children = [menuListItemText]; + + return [menuListItemOpen, menuListItemInline, menuListItemClose]; + }); + + dropdownTokens.push(dropdownMenuOpen, ...menuTokens, dropdownMenuClose); + + for (let i = 0; i < tabs.length; i++) { + const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1); + const tabPanelClose = new state.Token('tab-panel_close', 'div', -1); + + const tab = tabs[i]; + const tabId = getTabId(tab, {runId}); + const didTabHasActiveAttr = isTabSelected(tab); + + tab.name = getName(tab); + + const tabPanelId = generateID(); + + tabPanelOpen.block = true; + tabPanelClose.block = true; + tabPanelOpen.attrSet('id', tabPanelId); + tabPanelOpen.attrSet('class', TAB_PANEL_CLASSNAME); + tabPanelOpen.attrSet('role', 'tabpanel'); + tabPanelOpen.attrSet('aria-labelledby', tabId); + tabPanelOpen.attrSet('data-title', tab.name); + + if (didTabHasActiveAttr) { + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); + } + + dropdownTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose); + } + + dropdownTokens.push(dropdownClose); + + return dropdownTokens; +}; diff --git a/src/plugin/variants/index.ts b/src/plugin/variants/index.ts new file mode 100644 index 0000000..fb97409 --- /dev/null +++ b/src/plugin/variants/index.ts @@ -0,0 +1,18 @@ +import {TabsVariants} from '../../common'; + +import {regular} from './regular'; +import {radio} from './radio'; +import {TabsTokensGenerator} from './types'; +import {dropdown} from './dropdown'; +import {accordion} from './accordion'; + +const generateByType: Record = { + regular, + radio, + dropdown, + accordion, +}; + +export const generateTokensByType = (type: TabsVariants) => { + return generateByType[type]; +}; diff --git a/src/plugin/variants/radio.ts b/src/plugin/variants/radio.ts new file mode 100644 index 0000000..77eab80 --- /dev/null +++ b/src/plugin/variants/radio.ts @@ -0,0 +1,141 @@ +import type StateCore from 'markdown-it/lib/rules_core/state_core'; + +import { + ACTIVE_CLASSNAME, + GROUP_DATA_KEY, + TABS_CLASSNAME, + TABS_RADIO_CLASSNAME, + TAB_ACTIVE_KEY, + TAB_CLASSNAME, + TAB_DATA_ID, + TAB_DATA_KEY, + TAB_DATA_VARIANT, + TAB_DATA_VERTICAL_TAB, + TAB_FORCED_OPEN, + TAB_GROUP_CLASSNAME, + TAB_PANEL_CLASSNAME, + VERTICAL_TAB_CLASSNAME, +} from '../../common'; +import {generateID, getName, getTabId, getTabKey, isTabSelected} from '../utils'; +import {type RuntimeTab} from '../types'; + +import {type TabsTokensGenerator} from './types'; + +export const radio: TabsTokensGenerator = ( + tabs: RuntimeTab[], + state: StateCore, + {containerClasses, tabsGroup, runId}, +) => { + const tabsTokens = []; + const tabsOpen = new state.Token('tabs_open', 'div', 1); + const tabsClose = new state.Token('tabs_close', 'div', -1); + + if (tabs.length) { + const [start] = tabs[0].listItem.map ?? [null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (start == null) { + throw new Error('failed to parse line mapping'); + } + + const [_, end] = tabs[tabs.length - 1].listItem.map ?? [null, null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (end == null) { + throw new Error('failed to parse line mapping'); + } + } + + tabsOpen.block = true; + tabsClose.block = true; + + const activeTabsCount = tabs.filter(isTabSelected).length; + + if (activeTabsCount > 1) { + throw new Error('Unable to render tabs with more than 1 active element'); + } + + tabsOpen.attrSet( + 'class', + [TABS_CLASSNAME, containerClasses, TABS_RADIO_CLASSNAME].filter(Boolean).join(' '), + ); + + tabsOpen.attrSet(GROUP_DATA_KEY, tabsGroup); + tabsOpen.attrSet(TAB_DATA_VARIANT, 'radio'); + 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); + const tabText = new state.Token('text', '', 0); + const tabClose = new state.Token('tab_close', 'div', -1); + const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1); + const tabPanelClose = new state.Token('tab-panel_close', 'div', -1); + + const verticalTabInput = new state.Token('tab-input', 'input', 0); + const verticalTabLabelOpen = new state.Token('tab-label_open', 'label', 1); + const verticalTabLabelClose = new state.Token('tab-label_close', '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); + const didTabHasActiveAttr = isTabSelected(tab); + + tab.name = getName(tab); + + const tabPanelId = generateID(); + + verticalTabInput.block = true; + + verticalTabInput.attrJoin('class', 'radio'); + verticalTabInput.attrSet('type', 'radio'); + + tabOpen.map = tabs[i].listItem.map; + tabOpen.markup = tabs[i].listItem.markup; + tabText.content = tabs[i].name; + tabInline.children = [tabText]; + tabOpen.block = true; + tabClose.block = true; + tabPanelOpen.block = true; + tabPanelClose.block = true; + tabOpen.attrSet(TAB_DATA_ID, tabId); + tabOpen.attrSet(TAB_DATA_KEY, tabKey); + tabOpen.attrSet( + 'class', + [TAB_CLASSNAME, TAB_GROUP_CLASSNAME, VERTICAL_TAB_CLASSNAME].join(' '), + ); + 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'); + tabPanelOpen.attrSet('aria-labelledby', tabId); + tabPanelOpen.attrSet('data-title', tab.name); + tabOpen.attrSet(TAB_DATA_VERTICAL_TAB, 'true'); + + if (didTabHasActiveAttr) { + tabOpen.attrSet(TAB_FORCED_OPEN, 'true'); + verticalTabInput.attrSet('checked', 'true'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); + } + + tabsTokens.push( + tabOpen, + verticalTabInput, + verticalTabLabelOpen, + tabInline, + verticalTabLabelClose, + tabClose, + ); + + tabsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose); + } + + tabsTokens.push(tabsClose); + + return tabsTokens; +}; diff --git a/src/plugin/variants/regular.ts b/src/plugin/variants/regular.ts new file mode 100644 index 0000000..4a1c71e --- /dev/null +++ b/src/plugin/variants/regular.ts @@ -0,0 +1,169 @@ +import { + ACTIVE_CLASSNAME, + GROUP_DATA_KEY, + TABS_CLASSNAME, + TABS_LIST_CLASSNAME, + TAB_ACTIVE_KEY, + TAB_CLASSNAME, + TAB_DATA_ID, + TAB_DATA_KEY, + TAB_DATA_VARIANT, + TAB_DATA_VERTICAL_TAB, + TAB_FORCED_OPEN, + TAB_PANEL_CLASSNAME, + VERTICAL_TAB_CLASSNAME, +} from '../../common'; +import {generateID, getName, getTabId, getTabKey, isTabSelected} from '../utils'; + +import {TabsTokensGenerator} from './types'; + +export const regular: TabsTokensGenerator = ( + tabs, + state, + {containerClasses, tabsGroup, runId, orientation}, +) => { + const tabsTokens = []; + const tabListTokens = []; + const tabPanelsTokens = []; + const tabsOpen = new state.Token('tabs_open', 'div', 1); + const tabsClose = new state.Token('tabs_close', 'div', -1); + const tabListOpen = new state.Token('tab-list_open', 'div', 1); + const tabListClose = new state.Token('tab-list_close', 'div', -1); + + if (tabs.length) { + const [start] = tabs[0].listItem.map ?? [null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (start == null) { + throw new Error('failed to parse line mapping'); + } + + const [_, end] = tabs[tabs.length - 1].listItem.map ?? [null, null]; + // eslint-disable-next-line no-eq-null, eqeqeq + if (end == null) { + throw new Error('failed to parse line mapping'); + } + + tabListOpen.map = [start, end]; + } + + tabsOpen.block = true; + tabsClose.block = true; + tabListOpen.block = true; + tabListClose.block = true; + + 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', [TABS_CLASSNAME, containerClasses].filter(Boolean).join(' ')); + tabsOpen.attrSet(GROUP_DATA_KEY, tabsGroup); + tabsOpen.attrSet(TAB_DATA_VARIANT, 'regular'); + + tabListOpen.attrSet('class', TABS_LIST_CLASSNAME); + tabListOpen.attrSet('role', 'tablist'); + + if (orientation === 'radio') { + 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); + const tabText = new state.Token('text', '', 0); + const tabClose = new state.Token('tab_close', 'div', -1); + const tabPanelOpen = new state.Token('tab-panel_open', 'div', 1); + const tabPanelClose = new state.Token('tab-panel_close', 'div', -1); + + const verticalTabInput = new state.Token('tab-input', 'input', 0); + const verticalTabLabelOpen = new state.Token('tab-label_open', 'label', 1); + const verticalTabLabelClose = new state.Token('tab-label_close', '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); + 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 : i === 0; + + tab.name = getName(tab); + + const tabPanelId = generateID(); + + verticalTabInput.block = true; + + verticalTabInput.attrJoin('class', 'radio'); + verticalTabInput.attrSet('type', 'radio'); + + tabOpen.map = tabs[i].listItem.map; + tabOpen.markup = tabs[i].listItem.markup; + tabText.content = tabs[i].name; + tabInline.children = [tabText]; + tabOpen.block = true; + tabClose.block = true; + tabPanelOpen.block = true; + tabPanelClose.block = true; + tabOpen.attrSet(TAB_DATA_ID, tabId); + tabOpen.attrSet(TAB_DATA_KEY, tabKey); + 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'); + tabPanelOpen.attrSet('aria-labelledby', tabId); + tabPanelOpen.attrSet('data-title', tab.name); + + if (orientation === 'radio') { + tabOpen.attrSet(TAB_DATA_VERTICAL_TAB, 'true'); + tabOpen.attrJoin('class', VERTICAL_TAB_CLASSNAME); + } + + if (isTabActive) { + if (orientation === 'radio') { + tabOpen.attrSet(TAB_FORCED_OPEN, 'true'); + verticalTabInput.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'); + tabPanelOpen.attrJoin('class', ACTIVE_CLASSNAME); + } + } + + if (orientation === 'radio') { + tabsTokens.push( + tabOpen, + verticalTabInput, + verticalTabLabelOpen, + tabInline, + verticalTabLabelClose, + tabClose, + ); + tabsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose); + } else { + tabListTokens.push(tabOpen, tabInline, tabClose); + tabPanelsTokens.push(tabPanelOpen, ...tabs[i].tokens, tabPanelClose); + } + } + + tabsTokens.push(tabsOpen); + tabsTokens.push(tabListOpen); + tabsTokens.push(...tabListTokens); + tabsTokens.push(tabListClose); + tabsTokens.push(...tabPanelsTokens); + tabsTokens.push(tabsClose); + + return tabsTokens; +}; diff --git a/src/plugin/variants/types.ts b/src/plugin/variants/types.ts new file mode 100644 index 0000000..1748874 --- /dev/null +++ b/src/plugin/variants/types.ts @@ -0,0 +1,23 @@ +import type Token from 'markdown-it/lib/token'; +import type StateCore from 'markdown-it/lib/rules_core/state_core'; +import type {RuntimeTab} from '../types'; + +import {TabsVariants} from '../../common'; + +export type TokensRange = { + start: number; + end: number; +}; + +export type TabsGenerationProps = { + containerClasses: string; + tabsGroup: string; + runId: string; + orientation: TabsVariants; +}; + +export type TabsTokensGenerator = ( + tabs: RuntimeTab[], + state: StateCore, + props: TabsGenerationProps, +) => Token[]; diff --git a/src/runtime/TabsController.ts b/src/runtime/TabsController.ts index 8a5ba51..2609bea 100644 --- a/src/runtime/TabsController.ts +++ b/src/runtime/TabsController.ts @@ -1,19 +1,20 @@ -import type {TabsOrientation} from '../plugin/transform'; - import { ACTIVE_CLASSNAME, DEFAULT_TABS_GROUP_PREFIX, GROUP_DATA_KEY, SelectedTabEvent, TABS_CLASSNAME, + TABS_DROPDOWN_SELECT, TABS_LIST_CLASSNAME, - TABS_VERTICAL_CLASSNAME, + TABS_RADIO_CLASSNAME, TAB_CLASSNAME, TAB_DATA_ID, TAB_DATA_KEY, + TAB_DATA_VARIANT, + TAB_FORCED_OPEN, TAB_PANEL_CLASSNAME, Tab, - VERTICAL_TAB_FORCED_OPEN, + TabsVariants, } from '../common'; import { @@ -29,7 +30,7 @@ const Selector = { TAB_LIST: `.${TABS_LIST_CLASSNAME}`, TAB: `.${TAB_CLASSNAME}`, TAB_PANEL: `.${TAB_PANEL_CLASSNAME}`, - VERTICAL_TABS: `.${TABS_VERTICAL_CLASSNAME}`, + VERTICAL_TABS: `.${TABS_RADIO_CLASSNAME}`, }; export interface ISelectTabByIdOptions { @@ -42,21 +43,30 @@ type TabSwitchDirection = 'left' | 'right'; export class TabsController { private _document: Document; - private _onSelectTabHandlers: Set = new Set(); // TODO: remove side effects from constructor constructor(document: Document) { this._document = document; + this._document.addEventListener('click', (event) => { const target = getEventTarget(event) as HTMLElement; - const areVertical = this.areTabsVertical(target); + + if (event.target) { + this.hideAllDropdown(event.target as HTMLElement); + } if (isCustom(event)) { return; } - if (!(this.isValidTabElement(target) || areVertical)) { + if (this.isElementDropdownSelect(target)) { + target.classList.toggle(ACTIVE_CLASSNAME); + + return; + } + + if (!this.isValidTabElement(target)) { return; } @@ -66,6 +76,7 @@ export class TabsController { this._selectTab(tab, target); } }); + this._document.addEventListener('keydown', (event) => { let direction: TabSwitchDirection | null = null; switch (event.key) { @@ -146,7 +157,7 @@ export class TabsController { } private _selectTab(tab: Tab, targetTab?: HTMLElement) { - const {group, key, align} = tab; + const {group, key, variant} = tab; if (!group) { return; @@ -156,10 +167,10 @@ export class TabsController { const previousTargetOffset = scrollableParent && getOffsetByScrollableParent(targetTab, scrollableParent); - const updatedTabs = this.updateHTML({group, key, align}, targetTab, align); + const updatedTabs = this.updateHTML({group, key, variant}, targetTab, variant); if (updatedTabs > 0) { - this.fireSelectTabEvent({group, key, align}, targetTab?.dataset.diplodocId); + this.fireSelectTabEvent({group, key, variant}, targetTab?.dataset.diplodocId); if (previousTargetOffset) { this.resetScroll(targetTab, scrollableParent, previousTargetOffset); @@ -167,38 +178,38 @@ export class TabsController { } } - private updateHTML( - tab: Required, - target: HTMLElement | undefined, - align: TabsOrientation, - ) { - switch (align) { - case 'radio': { - return this.updateHTMLVertical(tab, target); + private updateHTML(tab: Required, target: HTMLElement | undefined, variant: TabsVariants) { + switch (variant) { + case TabsVariants.Radio: { + return this.updateHTMLRadio(tab, target); } - case 'horizontal': { - return this.updateHTMLHorizontal(tab); + case TabsVariants.Accordion: { + return this.updateHTMLAccordion(tab, target); + } + case TabsVariants.Regular: { + return this.updateHTMLRegular(tab); + } + case TabsVariants.Dropdown: { + return this.updateHTMLDropdown(tab); } } return 0; } - private updateHTMLVertical(tab: Required, target: HTMLElement | undefined) { + private updateHTMLRadio(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 singleTabSelector = isForced ? `.yfm-vertical-tab[${TAB_FORCED_OPEN}="true"]` : ''; const tabs = this._document.querySelectorAll( `${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]${singleTabSelector}`, ); if (isForced) { - root?.removeAttribute(VERTICAL_TAB_FORCED_OPEN); + root?.removeAttribute(TAB_FORCED_OPEN); } let updated = 0; @@ -245,7 +256,7 @@ export class TabsController { return updated; } - private updateHTMLHorizontal(tab: Required) { + private updateHTMLRegular(tab: Required) { const {group, key} = tab; const tabs = this._document.querySelectorAll( @@ -288,6 +299,114 @@ export class TabsController { return updated; } + private updateHTMLDropdown(tab: Required) { + const {group, key} = tab; + + const tabs = this._document.querySelectorAll( + `${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]`, + ); + + let changed = 0; + + tabs.forEach((tab) => { + const dropdown = tab.closest(`[${TAB_DATA_VARIANT}=${TabsVariants.Dropdown}]`); + + if (!dropdown?.children) { + return; + } + + const select = dropdown.children.item(0) as HTMLElement; + const menu = dropdown.children.item(1); + + select?.classList.remove(ACTIVE_CLASSNAME); + + /* first and second elements are select / menu, skipping them */ + const changedIndex = Array.from(menu?.children || []).indexOf(tab) + 2; + + for (let i = 2; i < dropdown.children.length; i++) { + const item = dropdown.children.item(i) as HTMLElement; + const menuItem = menu?.children.item(i - 2) as HTMLElement; + + changed++; + + if (changedIndex === i) { + item?.classList.add(ACTIVE_CLASSNAME); + menuItem.classList.add(ACTIVE_CLASSNAME); + + select.innerHTML = tab.innerHTML; + select.classList.add('filled'); + + continue; + } + + menuItem.classList.remove(ACTIVE_CLASSNAME); + item.classList.remove(ACTIVE_CLASSNAME); + } + }); + + return changed; + } + + private updateHTMLAccordion(tab: Required, target: HTMLElement | undefined) { + const {group, key} = tab; + + const tabs = this._document.querySelectorAll( + `${Selector.TABS}[${GROUP_DATA_KEY}="${group}"] ${Selector.TAB}[${TAB_DATA_KEY}="${key}"]`, + ); + + let changed = 0; + + tabs.forEach((tab) => { + const accordion = tab.closest(`[${TAB_DATA_VARIANT}=${TabsVariants.Accordion}]`); + + if (!accordion?.children) { + return; + } + + for (let i = 0; i < accordion.children.length; i += 2) { + const title = accordion.children.item(i); + const currentTab = accordion.children.item(i + 1); + + changed++; + + if (tab === title) { + title?.classList.toggle(ACTIVE_CLASSNAME); + currentTab?.classList.toggle(ACTIVE_CLASSNAME); + + continue; + } + + title?.classList.remove(ACTIVE_CLASSNAME); + currentTab?.classList.remove(ACTIVE_CLASSNAME); + } + }); + + if (target && !this.checkVisible(target)) { + setTimeout(() => { + target.scrollIntoView({block: 'nearest'}); + }); + } + + return changed; + } + + private checkVisible(element: HTMLElement) { + const rect = element.getBoundingClientRect(); + const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight); + + return !(rect.bottom < 0 || rect.top - viewHeight >= 0); + } + + private hideAllDropdown(target: HTMLElement) { + const dropdowns = this._document.querySelectorAll('.yfm-tabs-dropdown-select.active'); + + dropdowns.forEach((menu) => { + if (!menu.contains(target)) { + menu.classList.remove(ACTIVE_CLASSNAME); + } + }); + } + private resetScroll( target: HTMLElement, scrollableParent: HTMLElement, @@ -309,51 +428,68 @@ export class TabsController { return {}; } + if (target.dataset.diplodocForced) { + return {root: target, isForced: true}; + } + const root = target.dataset.diplodocVerticalTab ? target : target.parentElement; - const isForced = typeof root?.dataset.diplodocRadioForced !== 'undefined'; + const isForced = typeof root?.dataset.diplodocForced !== 'undefined'; return {root, isForced}; } private fireSelectTabEvent(tab: Required, diplodocId?: string) { - const {group, key, align} = tab; + const {group, key, variant: align} = tab; - const eventTab: Tab = group.startsWith(DEFAULT_TABS_GROUP_PREFIX) ? {key, align} : tab; + const eventTab: Tab = group.startsWith(DEFAULT_TABS_GROUP_PREFIX) + ? {key, variant: align} + : tab; this._onSelectTabHandlers.forEach((handler) => { handler({tab: eventTab, currentTabId: diplodocId}); }); } - private isValidTabElement(element: HTMLElement) { - const tabList = - element.matches(Selector.TAB) && element.dataset.diplodocId - ? element.closest(Selector.TAB_LIST) - : null; + private getTabsType(element: HTMLElement) { + const tabsRoot = element.closest(`[${TAB_DATA_VARIANT}]`) as HTMLElement | undefined; + + if (!tabsRoot) { + return undefined; + } - return tabList?.closest(Selector.TABS); + return tabsRoot.dataset.diplodocVariant; } - private areTabsVertical(target: HTMLElement) { - const parent = target.parentElement; + private isValidTabElement(element: HTMLElement) { + return Boolean(this.getTabsType(element)); + } - return target.dataset.diplodocVerticalTab || Boolean(parent?.dataset.diplodocVerticalTab); + private isElementDropdownSelect(target: HTMLElement) { + return target.classList.contains(TABS_DROPDOWN_SELECT); } private getTabDataFromHTMLElement(target: HTMLElement): Tab | null { - if (this.areTabsVertical(target)) { + const type = this.getTabsType(target); + + if (type === TabsVariants.Radio) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 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: 'radio'} : null; + return key && group ? {group, key, variant: TabsVariants.Radio} : null; + } + + if (type === TabsVariants.Dropdown || type === TabsVariants.Accordion) { + const key = target.dataset.diplodocKey; + const group = (target.closest(Selector.TABS) as HTMLElement)?.dataset.diplodocGroup; + return key && group ? {group, key, variant: type} : null; } const key = target.dataset.diplodocKey; const group = (target.closest(Selector.TABS) as HTMLElement)?.dataset.diplodocGroup; - return key && group ? {group, key, align: 'horizontal'} : null; + return key && group ? {group, key, variant: TabsVariants.Regular} : null; } private getTabs(target: HTMLElement): {tabs: Tab[]; nodes: NodeListOf} { @@ -373,7 +509,7 @@ export class TabsController { tabs.push({ group, key, - align: 'horizontal', + variant: TabsVariants.Regular, }); }); diff --git a/src/runtime/scss/tabs.scss b/src/runtime/scss/tabs.scss index 341349c..d99c1d1 100644 --- a/src/runtime/scss/tabs.scss +++ b/src/runtime/scss/tabs.scss @@ -22,6 +22,234 @@ border-bottom: 1px solid rgba(0, 0, 0, 0.07); } + +.yfm-tab { + margin-bottom: -1px; + margin-right: 20px; + border-bottom: 2px solid transparent; + + padding: 6px 0 7px 0; + font-weight: 700; + outline: none; + cursor: pointer; + user-select: none; + white-space: break-spaces; + + &:last-child { + margin-right: 0; + } + + &:hover, &:active { + color: #004080; + } + + &.active { + border-bottom-color: #027bf3; + } + + &:focus { + outline: 2px solid #4d90fe; + border-radius: 2px; + } + &:focus:not(:focus-visible) { + outline: 0; + } +} + +.yfm-tab-panel { + position: relative; + visibility: hidden; + overflow: hidden; + height: 0; + + &:first-child { + margin-top: 0 !important; + } + + &:last-child { + margin-bottom: 0 !important; + } + + &.active { + visibility: visible; + overflow: visible; + height: auto; + } +} + + +.yfm-tabs-dropdown { + position: relative; + width: 90%; + margin-bottom: 0; + + --yfm-tabs-dropdown-color: #F2F2F2; + --yfm-tabs-dropdown-text-color: #A8A8A8; + --yfm-tabs-dropdown-color-border: #CCC; + + .yfm-tabs-dropdown-select { + padding: 10px 30px 10px 10px; + background: var(--g-color-base-background); + border: 1px solid var(--yfm-tabs-dropdown-color-border); + color: var(--yfm-tabs-dropdown-text-color); + border-radius: 5px; + position: relative; + user-select: none; + margin-bottom: 10px; + max-width: 500px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + z-index: 10; + + &.filled { + color: inherit; + } + + + &::after { + content: ''; + position: absolute; + display: block; + right: 10px; + top: 25%; + transform: rotate(90deg); + transition: transform 200ms; + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIGQ9Im0zIDYgNSA1IDUtNSIvPjwvc3ZnPg==); + width: 20px; + height: 20px; + } + + &.active { + & + .yfm-tabs-dropdown-menu { + transform: scaleY(1); + } + + &::after { + transform: rotate(0deg); + } + } + } + + & > .yfm-tabs-dropdown-menu { + width: 100%; + max-width: 500px; + max-height: 500px; + overflow-y: scroll; + padding: 10px 0px; + position: absolute; + z-index: 10; + + display: flex; + flex-direction: column; + + border: 1px solid var(--yfm-tabs-dropdown-color-border); + border-radius: 5px; + background-color: var(--g-color-base-background); + transform: scaleY(0); + + list-style: none; + z-index: 100; + + transition: transform 125ms; + transform-origin: top center; + + & > li { + user-select: none; + cursor: pointer; + background-color: var(--g-color-base-background); + + &:hover { + background-color: var(--g-color-base-simple-hover); + color: inherit; + } + + &.yfm-tab { + padding: 5px 0 5px 10px; + border-radius: 0; + border-bottom: unset; + margin: 0; + font-weight: 500; + + &.active { + background-color: var(--g-color-base-selection); + } + + &:hover { + color: inherit; + } + } + } + } + + & > .yfm-tab-panel { + &.active { + border: 1px solid #F2F2F2; + transform: translateY(-30px); + border-radius: 12px; + padding: 40px 12px 16px 12px; + } + } + + & > .yfm-tabs { + width: 100%; + } +} + +.yfm-tabs-accordion { + max-width: 80%; + + & > .yfm-tab:first-of-type { + border-top-color: transparent; + } + + .yfm-tab { + padding: 8px 0 12px 6px;; + position: relative; + + border-top: 1px solid #E5E5E5; + font-weight: 500; + + scroll-margin-top: 20px; + + &::after { + content: ''; + position: absolute; + display: block; + right: 10px; + top: 25%; + transform: rotate(-180deg); + transition: + transform 300ms, + background-color 200ms; + background-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSJjdXJyZW50Q29sb3IiIGQ9Im0zIDYgNSA1IDUtNSIvPjwvc3ZnPg==); + width: 20px; + height: 20px; + border-radius: 6px; + padding: 6px; + } + + &.active { + border-bottom-color: transparent !important; + font-weight: 700; + + &::after { + transform: rotate(0); + } + } + + &:hover { + color: #3F5799; + } + } + + .yfm-tab-panel { + &.active { + margin: 10px 0 25px 6px; + } + } +} + .yfm-tabs-vertical { & > .yfm-tab-list { flex-direction: column; @@ -86,56 +314,3 @@ } } - -.yfm-tab { - margin-bottom: -1px; - margin-right: 20px; - border-bottom: 2px solid transparent; - - padding: 6px 0 7px 0; - font-weight: 700; - outline: none; - cursor: pointer; - user-select: none; - white-space: break-spaces; - - &:last-child { - margin-right: 0; - } - - &:hover, &:active { - color: #004080; - } - - &.active { - border-bottom-color: #027bf3; - } - - &:focus { - outline: 2px solid #4d90fe; - border-radius: 2px; - } - &:focus:not(:focus-visible) { - outline: 0; - } -} - -.yfm-tab-panel { - position: relative; - visibility: hidden; - overflow: hidden; - height: 0; - - &:first-child { - margin-top: 0 !important; - } - - &:last-child { - margin-bottom: 0 !important; - } - - &.active { - visibility: visible; - height: auto; - } -} diff --git a/tests/src/__snapshots__/plugin.test.ts.snap b/tests/src/__snapshots__/plugin.test.ts.snap index 18d1053..a96894d 100644 --- a/tests/src/__snapshots__/plugin.test.ts.snap +++ b/tests/src/__snapshots__/plugin.test.ts.snap @@ -3,6 +3,7 @@ exports[`plugin html snapshots should render common tabs 1`] = `