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;