import { ProjectPluginStructure, UIDLAttributeValue, UIDLConditionalNode, UIDLElement, UIDLElementNode, UIDLNode, UIDLStyleDefinitions, UIDLStyleValue, } from '@teleporthq/teleport-types' import { traverseProjectElements } from '../uidl-element-traversal' /** * Codegen-side decomposition of the Collapsible Text primitive. * * The GUI's `collapsibleTextNodeToUIDL` mapper is *supposed* to bake the * clamp + overflow markers into the exported UIDL, but a project can be exported * without that decomposition (the primitive then arrives as the raw * `collapsible-text` element carrying only its content paragraph and the two * toggle labels). Doing the decomposition here as well means the generated app * clamps + gates the "Show more" toggle no matter how the UIDL was produced — * and stays idempotent when the UIDL *is* already decomposed. * * `state → style` is not expressible in the generated code, so instead of * toggling a line-clamp the body is emitted as TWO mutually-exclusive views of * the same content: a *collapsed* view carrying a static `-webkit-line-clamp` * (rendered when `expanded === false`) and an *expanded* view without it. Only * one mounts at runtime. The collapsed view is tagged `data-tq-collapsible-clamp` * and the Show more label `data-tq-collapsible-more` so the shipped * `TqCollapsibleTextOverflow` helper can measure real overflow and hide the * toggle when the text already fits. */ export const COLLAPSIBLE_TEXT_ELEMENT_TYPE = 'collapsible-text' export const DEFAULT_COLLAPSIBLE_TRUNCATE_LINES = 3 const ROOT_MARKER = 'data-tq-collapsible-root' const CLAMP_MARKER = 'data-tq-collapsible-clamp' const MORE_MARKER = 'data-tq-collapsible-more' // Optional hint the GUI can stamp on the raw element so a custom line count // survives even when the full decomposition mapper did not run. Consumed and // stripped here so it never reaches the DOM. const LINES_ATTR = 'data-tq-collapsible-lines' // Deterministic, collision-free keys for the wrapper `
`s across one run. let bodyKeyCounter = 0 const staticAttr = (content: string): UIDLAttributeValue => ({ type: 'static', content }) const staticStyle = (content: string): UIDLStyleValue => ({ type: 'static', content }) const deepCloneNode = (node: UIDLNode): UIDLNode => JSON.parse(JSON.stringify(node)) as UIDLNode const readTruncateLines = (element: UIDLElement): number => { const raw = element.attrs?.[LINES_ATTR]?.content const parsed = typeof raw === 'string' || typeof raw === 'number' ? parseInt(String(raw), 10) : NaN return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_COLLAPSIBLE_TRUNCATE_LINES } const makeContentWrapper = ( inner: UIDLNode, style: UIDLStyleDefinitions, markClamp: boolean ): UIDLElementNode => { bodyKeyCounter += 1 const content: UIDLElement = { elementType: 'container', semanticType: 'div', key: `collapsible-body-${bodyKeyCounter}`, children: [inner], } if (Object.keys(style).length > 0) { content.style = style } if (markClamp) { content.attrs = { [CLAMP_MARKER]: staticAttr('true') } } return { type: 'element', content } } /** * Builds a body view that gates on the SAME condition as `labelNode` (Show more * → `expanded === false`, See less → `expanded === true`), wrapping `content` in * a `
` that carries `extraStyle` (the line-clamp for the collapsed view). * Handles both shapes: a `conditional` wrapper (what the generator emits) or an * element with inline `renderingConditions`. */ const wrapContentWithLabelCondition = ( labelNode: UIDLNode, content: UIDLNode, extraStyle: UIDLStyleDefinitions, markClamp: boolean ): UIDLNode => { const wrapper = makeContentWrapper(content, extraStyle, markClamp) if (labelNode.type === 'conditional') { return { type: 'conditional', content: { ...(labelNode as UIDLConditionalNode).content, node: wrapper, }, } } if (labelNode.type === 'element' && labelNode.content.renderingConditions) { wrapper.content.renderingConditions = JSON.parse( JSON.stringify(labelNode.content.renderingConditions) ) return wrapper } // Label wasn't conditionally wrapped as expected — fall back to always-visible // so the content still renders (the labels then govern the toggle affordance). return wrapper } /** * Tags the Show more label (bare element or `conditional` wrapper) so the runtime * overflow helper can hide it when the clamped text does not actually overflow. */ const addMarkerAttr = (node: UIDLNode, key: string): void => { const target = node.type === 'conditional' ? (node as UIDLConditionalNode).content.node : node if (target && target.type === 'element') { target.content.attrs = { ...(target.content.attrs ?? {}), [key]: staticAttr('true'), } } } /** * Decomposes a single `collapsible-text` element IN PLACE. No-op when the element * is already decomposed (root marker present) or isn't the expected * content + Show more + See less shape. */ export const decomposeCollapsibleTextElement = (element: UIDLElement): void => { // Already decomposed by the GUI mapper — only strip the transient line hint so // it never reaches the DOM, then leave the structure untouched. if (element.attrs?.[ROOT_MARKER]) { if (element.attrs[LINES_ATTR]) { delete element.attrs[LINES_ATTR] } return } const children = element.children ?? [] const contentNode = children[0] const showMoreNode = children[1] const seeLessNode = children[2] if (!contentNode || !showMoreNode || !seeLessNode) { return } const truncateLines = readTruncateLines(element) const clampStyle: UIDLStyleDefinitions = { display: staticStyle('-webkit-box'), overflow: staticStyle('hidden'), ['-webkit-line-clamp']: staticStyle(String(truncateLines)), ['-webkit-box-orient']: staticStyle('vertical'), } const collapsedView = wrapContentWithLabelCondition(showMoreNode, contentNode, clampStyle, true) const expandedView = wrapContentWithLabelCondition( seeLessNode, deepCloneNode(contentNode), {}, false ) addMarkerAttr(showMoreNode, MORE_MARKER) const attrs = { ...(element.attrs ?? {}) } delete attrs[LINES_ATTR] attrs[ROOT_MARKER] = staticAttr('true') element.attrs = attrs element.children = [collapsedView, expandedView, showMoreNode, seeLessNode] } /** * Walks every page + component in the project UIDL and decomposes each * `collapsible-text` element. Returns true if the primitive was used anywhere. */ export const decomposeCollapsibleTextInProject = ( uidl: ProjectPluginStructure['uidl'] ): boolean => { let found = false traverseProjectElements(uidl, (element) => { if (element.elementType === COLLAPSIBLE_TEXT_ELEMENT_TYPE) { found = true decomposeCollapsibleTextElement(element) } }) return found }