diff --git a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemLabel.tsx b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemLabel.tsx index 00ed4bf78..fe7b7ee6b 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemLabel.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemLabel.tsx @@ -67,6 +67,15 @@ const ItemLabel = memo(function ItemLabel(props: ItemLabelProps) { sx={{ mt: 0.5, flexGrow: 1, + // A question label is a single run of text, so the outer paragraph margins that xhtml/markdown + // rendering adds would only push it out of line with its field. Display items are standalone + // blocks of prose, so they keep their paragraph spacing. + ...(isDisplayItem + ? {} + : { + '& p:first-of-type': { marginBlockStart: 0 }, + '& p:last-of-type': { marginBlockEnd: 0 } + }), ...(parentStyles || {}) }}> {/* Required asterisk position is in front of text */} @@ -79,12 +88,17 @@ const ItemLabel = memo(function ItemLabel(props: ItemLabelProps) { ) : null} + {/* verticalAlign: top is required because an inline-flex box takes its baseline from its first + flex item. Once the label text wraps onto multiple lines, that baseline sits well below the + surrounding line box's baseline, which pushes the whole label down and away from its field. + Aligning the box to the top of the line box keeps multi-line labels in line with their fields. */} + flexWrap="wrap" + sx={{ verticalAlign: 'top' }}> diff --git a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QRenderingTextAppearance.ts b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QRenderingTextAppearance.ts index 405d485fb..496cf7514 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QRenderingTextAppearance.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QRenderingTextAppearance.ts @@ -905,3 +905,40 @@ export const qRenderingAnswerOptionValueString: Questionnaire = { } ] }; + +// Labels long enough to wrap onto multiple lines, both as plain text and as rendering-xhtml. +// Guards against https://github.com/aehrc/smart-forms/issues/2048, where a wrapping label was +// pushed below its field instead of staying in line with it. +export const qWrappingItemLabels: Questionnaire = { + resourceType: 'Questionnaire', + status: 'draft', + item: [ + { + linkId: 'wrapping-label-plain', + text: 'Do you have access to the Aus CVD Risk-i application from your clinical system?', + type: 'boolean', + repeats: false + }, + { + linkId: 'wrapping-label-xhtml', + text: 'Do you have access to the Aus CVD Risk-i application from your clinical system?', + _text: { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/rendering-xhtml', + valueString: + '

Do you have access to the Aus CVD Risk‑i application from your clinical system?

' + } + ] + }, + type: 'boolean', + repeats: false + }, + { + linkId: 'wrapping-label-string', + text: 'Do you have access to the Aus CVD Risk-i application from your clinical system, and if so, which version?', + type: 'string', + repeats: false + } + ] +}; diff --git a/packages/smart-forms-renderer/src/stories/sdc/RenderingTextAppearance.stories.tsx b/packages/smart-forms-renderer/src/stories/sdc/RenderingTextAppearance.stories.tsx index 80cdb27ff..feb9379ae 100644 --- a/packages/smart-forms-renderer/src/stories/sdc/RenderingTextAppearance.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/sdc/RenderingTextAppearance.stories.tsx @@ -36,9 +36,11 @@ import { qRenderingXhtmlGroupPropagationInlineStyles, qRenderingXhtmlGroupPropagationNested, qRenderingXhtmlPrefix, - qRenderingXhtmlTitle + qRenderingXhtmlTitle, + qWrappingItemLabels } from '../assets/questionnaires'; import { createStory } from '../storybookWrappers/createStory'; +import { expect } from 'storybook/test'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export const meta = { @@ -170,3 +172,63 @@ export const PrefixOnlyNoText: Story = createStory({ questionnaire: qPrefixOnlyNoText } }) as Story; + +// ── Wrapping item labels (Issue #2048) ─────────────────────────────────────── + +export const WrappingItemLabels: Story = createStory({ + args: { + questionnaire: qWrappingItemLabels + }, + play: async ({ canvasElement }) => { + // Client rects of every glyph run inside an element, top-most first. Ranges over the element + // itself are no good here - they also report zero-width rects for the empty inlines that xhtml + // rendering introduces, which sit above the text they wrap. + function getTextLineTops(element: Element): number[] { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); + const tops = new Set(); + + let textNode = walker.nextNode(); + while (textNode) { + if (textNode.textContent?.trim()) { + const range = document.createRange(); + range.selectNodeContents(textNode); + for (const rect of range.getClientRects()) { + if (rect.height > 0 && rect.width > 0) { + tops.add(rect.top); + } + } + } + textNode = walker.nextNode(); + } + + return [...tops].sort((a, b) => a - b); + } + + // A label that wraps onto multiple lines must still start at the top of its row, in line with + // its field, rather than being pushed below it. + for (const linkId of [ + 'wrapping-label-plain', + 'wrapping-label-xhtml', + 'wrapping-label-string' + ]) { + const row = canvasElement.querySelector(`[data-linkid="${linkId}"]`); + if (!row) { + throw new Error(`Row was not found for [data-linkid="${linkId}"]`); + } + + const label = row.querySelector(`#label-${linkId}`); + if (!label) { + throw new Error(`Label was not found for #label-${linkId}`); + } + + const lineTops = getTextLineTops(label); + + // Confirm the label actually wraps, otherwise this story isn't testing anything + expect(lineTops.length).toBeGreaterThan(1); + + // Only ItemLabel's 4px nudge and the line's half-leading may sit above the first line of text + const offsetFromRowTop = lineTops[0] - row.getBoundingClientRect().top; + expect(offsetFromRowTop).toBeLessThanOrEqual(12); + } + } +}) as Story;