Skip to content

Commit f640290

Browse files
Issue/1786 - Text styling on answerOption.valueString (aehrc#1791)
* refactor text styling into separate element and apply to answerOption.valueString * add story for answerOption.valueString rendering * add note in changelog * implement text styling on answerOption.valueString for checkboxes and selects * fix tests to read autocomplete value from tag instead of textarea/input * add other answerOption item controls to text styling story
1 parent a1bad69 commit f640290

18 files changed

Lines changed: 514 additions & 72 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ For changelogs of other libraries, please refer to their respective repositories
1010

1111
Changelog only includes changes from version 0.36.0 onwards.
1212

13+
## [1.2.12] - xxxx-xx-xx
14+
### Added
15+
- Added support for text styling on answerOption.valueString.
16+
1317
## [1.2.11] - 2025-10-28
1418
### Fixed
1519
- Fixed an issue where enableWhen items are not initialising when a filled form is passed into `buildForm()`.

packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/CheckboxOptionList.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type { QuestionnaireItemAnswerOption, QuestionnaireResponseItemAnswer } f
2020
import CheckboxSingle from '../ItemParts/CheckboxSingle';
2121
import { isOptionDisabled } from '../../../utils/choice';
2222
import { deepEqual } from 'fast-equals';
23+
import StyledText from '../ItemParts/StyledText';
2324

2425
interface CheckboxOptionListProps {
2526
options: QuestionnaireItemAnswerOption[];
@@ -70,7 +71,10 @@ function CheckboxOptionList(props: CheckboxOptionListProps) {
7071
<CheckboxSingle
7172
key={option.valueString}
7273
value={option.valueString}
73-
label={option.valueString}
74+
label={
75+
<StyledText textToDisplay={option.valueString} element={option._valueString} />
76+
}
77+
labelText={option.valueString}
7478
readOnly={readOnly}
7579
disabledViaToggleExpression={optionDisabledViaToggleExpression}
7680
fullWidth={fullWidth}

packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceRadioSingle.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { StandardRadio } from '../../Radio.styles';
2222

2323
interface ChoiceRadioSingleProps {
2424
value: string;
25-
label: string;
25+
label: React.ReactNode;
2626
readOnly: boolean;
2727
disabledViaToggleExpression: boolean;
2828
fullWidth: boolean;

packages/smart-forms-renderer/src/components/FormComponents/ChoiceItems/ChoiceSelectAnswerOptionFields.tsx

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import React from 'react';
1819
import Autocomplete from '@mui/material/Autocomplete';
1920
import type { QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4';
2021
import type {
@@ -28,6 +29,7 @@ import { StyledRequiredTypography } from '../Item.styles';
2829
import DisplayUnitText from '../ItemParts/DisplayUnitText';
2930
import ExpressionUpdateFadingIcon from '../ItemParts/ExpressionUpdateFadingIcon';
3031
import { StandardTextField } from '../Textfield.styles';
32+
import StyledText from '../ItemParts/StyledText';
3133

3234
interface ChoiceSelectAnswerOptionFieldsProps
3335
extends PropsWithIsTabledAttribute,
@@ -61,6 +63,12 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
6163

6264
const { displayUnit, displayPrompt, entryFormat } = renderingExtensions;
6365

66+
const [inputValue, setInputValue] = React.useState('');
67+
68+
// Keep track of current selected value when input is changed
69+
const [currentValueSelect, setCurrentValueSelect] =
70+
React.useState<QuestionnaireItemAnswerOption | null>(valueSelect);
71+
6472
return (
6573
<>
6674
<Autocomplete
@@ -70,9 +78,45 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
7078
getOptionDisabled={(option) => isOptionDisabled(option, answerOptionsToggleExpressionsMap)}
7179
getOptionLabel={(option) => getAnswerOptionLabel(option)}
7280
isOptionEqualToValue={(option, value) => compareAnswerOptionValue(option, value)}
73-
onChange={(_, newValue) => onSelectChange(newValue)}
81+
onChange={(_, newValue) => {
82+
onSelectChange(newValue);
83+
setCurrentValueSelect(newValue);
84+
}}
85+
inputValue={inputValue}
86+
onInputChange={(_, newInputValue, reason) => {
87+
if (!inputValue && valueSelect && reason !== 'clear') {
88+
// Convert current input value to be the current value plus additional input
89+
onSelectChange(null);
90+
setInputValue(getAnswerOptionLabel(valueSelect) + newInputValue);
91+
} else {
92+
setInputValue(newInputValue);
93+
}
94+
}}
95+
onBlur={() => {
96+
// Set value on blur if there is any current input
97+
if (currentValueSelect) {
98+
onSelectChange(currentValueSelect);
99+
setInputValue(''); // Clear input after blur
100+
}
101+
}}
102+
onKeyDown={(e) => {
103+
if (e.key === 'Backspace') {
104+
if (!inputValue && valueSelect) {
105+
// Convert current selection to input value on backspace when input is empty
106+
onSelectChange(null);
107+
setInputValue(getAnswerOptionLabel(valueSelect));
108+
}
109+
}
110+
}}
74111
autoHighlight
75-
sx={{ maxWidth: !isTabled ? textFieldWidth : 3000, minWidth: 160, flexGrow: 1 }}
112+
sx={{
113+
maxWidth: !isTabled ? textFieldWidth : 3000,
114+
minWidth: 160,
115+
flexGrow: 1,
116+
'& .MuiAutocomplete-tag': {
117+
mx: 0
118+
}
119+
}}
76120
size="small"
77121
disabled={readOnly && readOnlyVisualStyle === 'disabled'}
78122
readOnly={readOnly && readOnlyVisualStyle === 'readonly'}
@@ -93,6 +137,11 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
93137
<DisplayUnitText readOnly={readOnly}>{displayUnit}</DisplayUnitText>
94138
</>
95139
),
140+
sx: {
141+
'&.MuiOutlinedInput-root.MuiInputBase-sizeSmall .MuiAutocomplete-input': {
142+
paddingLeft: '0px'
143+
}
144+
},
96145
inputProps: {
97146
...params.inputProps,
98147
...(isTabled
@@ -104,6 +153,40 @@ function ChoiceSelectAnswerOptionFields(props: ChoiceSelectAnswerOptionFieldsPro
104153
}}
105154
/>
106155
)}
156+
renderOption={(optionProps, option) => {
157+
const { key, ...rest } = optionProps;
158+
return (
159+
<li key={key} {...rest}>
160+
<span>
161+
{option.valueString ? (
162+
<StyledText
163+
textToDisplay={getAnswerOptionLabel(option)}
164+
element={option._valueString}
165+
/>
166+
) : (
167+
getAnswerOptionLabel(option)
168+
)}
169+
</span>
170+
</li>
171+
);
172+
}}
173+
renderValue={(value, getItemProps) => {
174+
const selectedOption = options.find((opt) => opt.valueString === value.valueString);
175+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
176+
const { onDelete, ...rest } = getItemProps();
177+
return (
178+
<span {...rest}>
179+
{value.valueString && selectedOption ? (
180+
<StyledText
181+
textToDisplay={getAnswerOptionLabel(value)}
182+
element={selectedOption._valueString}
183+
/>
184+
) : (
185+
getAnswerOptionLabel(value)
186+
)}
187+
</span>
188+
);
189+
}}
107190
/>
108191

109192
{feedback ? <StyledRequiredTypography>{feedback}</StyledRequiredTypography> : null}

packages/smart-forms-renderer/src/components/FormComponents/GroupItem/GroupItemView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ function GroupItemView(props: GroupItemViewProps) {
8787
} = props;
8888

8989
// If XHTML has styles, pass them to the GroupItemView so it cna be applied down the tree
90-
const xhtmlStyles = useParseXhtml(qItem)?.styles;
90+
const xhtmlStyles = useParseXhtml(qItem._text, qItem.text)?.styles;
9191

9292
// Combine parent styles with this group's styles
9393
const combinedStyles = React.useMemo(() => {

packages/smart-forms-renderer/src/components/FormComponents/ItemParts/CheckboxSingle.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import { useRendererConfigStore } from '../../../stores';
2222

2323
interface CheckboxSingleProps {
2424
value: string;
25-
label: string;
25+
label: React.ReactNode;
26+
labelText?: string;
2627
readOnly: boolean;
2728
disabledViaToggleExpression: boolean;
2829
fullWidth: boolean;
@@ -34,6 +35,7 @@ function CheckboxSingle(props: CheckboxSingleProps) {
3435
const {
3536
value,
3637
label,
38+
labelText,
3739
readOnly,
3840
disabledViaToggleExpression,
3941
fullWidth,
@@ -80,7 +82,7 @@ function CheckboxSingle(props: CheckboxSingleProps) {
8082
}}
8183
slotProps={{
8284
input: {
83-
'aria-label': label ?? 'Unnamed checkbox'
85+
'aria-label': (typeof label === 'string' ? label : labelText) ?? 'Unnamed checkbox'
8486
}
8587
}}
8688
/>

packages/smart-forms-renderer/src/components/FormComponents/ItemParts/ItemLabel.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ import ItemTextSwitcher from './ItemTextSwitcher';
2727
import Typography from '@mui/material/Typography';
2828
import FlyoverItem from './FlyoverItem';
2929
import type { PropsWithParentStylesAttribute } from '../../../interfaces/renderProps.interface';
30-
import { structuredDataCapture } from 'fhir-sdc-helpers';
31-
import { default as parseStyleToJs } from 'style-to-js';
3230

3331
interface ItemLabelProps extends PropsWithParentStylesAttribute {
3432
qItem: QuestionnaireItem;
@@ -53,10 +51,6 @@ const ItemLabel = memo(function ItemLabel(props: ItemLabelProps) {
5351
const readOnlyTextColor = readOnlyVisualStyle === 'disabled' ? 'text.disabled' : 'text.secondary';
5452
const textColor = parentStyles?.color || (readOnly ? readOnlyTextColor : 'text.primary');
5553

56-
// Get styles from qItem._text
57-
const stylesString = structuredDataCapture.getStyle(qItem._text);
58-
const itemStyles = stylesString ? parseStyleToJs(stylesString) : {};
59-
6054
return (
6155
<Box display="flex" alignItems="center" justifyContent="space-between">
6256
<Box position="relative" display="flex" flexGrow={1} alignItems="center">
@@ -72,8 +66,7 @@ const ItemLabel = memo(function ItemLabel(props: ItemLabelProps) {
7266
sx={{
7367
mt: 0.5,
7468
flexGrow: 1,
75-
...(parentStyles || {}),
76-
...itemStyles
69+
...(parentStyles || {})
7770
}}>
7871
{/* Required asterisk position is in front of text */}
7972
{required && requiredIndicatorPosition === 'start' ? (
Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import React, { memo } from 'react';
2-
import ReactMarkdown from 'react-markdown';
3-
import { getMarkdownString } from '../../../utils/extensions';
4-
import { useParseXhtml } from '../../../hooks/useParseXhtml';
52
import useDisplayCqfAndCalculatedExpression from '../../../hooks/useDisplayCqfAndCalculatedExpression';
63
import type { QuestionnaireItem } from 'fhir/r4';
74
import { getItemTextToDisplay } from '../../../utils/itemTextToDisplay';
5+
import StyledText from './StyledText';
86

97
interface ItemTextSwitcherProps {
108
qItem: QuestionnaireItem;
@@ -24,29 +22,9 @@ const ItemTextSwitcher = memo(function ItemTextSwitcher({ qItem }: ItemTextSwitc
2422
const itemTextAriaLabel =
2523
useDisplayCqfAndCalculatedExpression(qItem, 'item._text.aria-label') ?? undefined;
2624

27-
// parse XHTML if found
28-
const parsedXhtml = useParseXhtml(qItem);
29-
if (parsedXhtml) {
30-
return <span aria-label={itemTextAriaLabel}>{parsedXhtml.content}</span>;
31-
}
32-
33-
// parse markdown if found
34-
const markdownString = getMarkdownString(qItem);
35-
if (markdownString) {
36-
return (
37-
<span aria-label={itemTextAriaLabel}>
38-
<ReactMarkdown>{markdownString}</ReactMarkdown>
39-
</span>
40-
);
41-
}
42-
43-
// labelText is empty, return null
44-
if (!itemTextToDisplay) {
45-
return null;
46-
}
25+
const content = <StyledText textToDisplay={itemTextToDisplay} element={qItem._text} />;
4726

48-
// parse regular text
49-
return <span aria-label={itemTextAriaLabel}>{itemTextToDisplay}</span>;
27+
return content ? <span aria-label={itemTextAriaLabel}>{content}</span> : null;
5028
});
5129

5230
export default ItemTextSwitcher;

packages/smart-forms-renderer/src/components/FormComponents/ItemParts/RadioOptionList.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import React from 'react';
1919
import ChoiceRadioSingle from '../ChoiceItems/ChoiceRadioSingle';
2020
import type { QuestionnaireItemAnswerOption } from 'fhir/r4';
2121
import { isOptionDisabled } from '../../../utils/choice';
22+
import StyledText from './StyledText';
2223

2324
interface RadioOptionListProps {
2425
options: QuestionnaireItemAnswerOption[];
@@ -56,7 +57,9 @@ function RadioOptionList(props: RadioOptionListProps) {
5657
<ChoiceRadioSingle
5758
key={option.valueString}
5859
value={option.valueString}
59-
label={option.valueString}
60+
label={
61+
<StyledText textToDisplay={option.valueString} element={option._valueString} />
62+
}
6063
readOnly={readOnly}
6164
disabledViaToggleExpression={optionDisabledViaToggleExpression}
6265
fullWidth={fullWidth}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React from 'react';
2+
import ReactMarkdown from 'react-markdown';
3+
import { getMarkdownString } from '../../../utils/extensions';
4+
import { useParseXhtml } from '../../../hooks/useParseXhtml';
5+
import type { Element } from 'fhir/r4';
6+
import { structuredDataCapture } from 'fhir-sdc-helpers';
7+
import { default as parseStyleToJs } from 'style-to-js';
8+
9+
interface StyledTextProps {
10+
textToDisplay: string | null;
11+
element: Element | undefined;
12+
}
13+
14+
function StyledText({ textToDisplay, element }: StyledTextProps) {
15+
// parse XHTML if found
16+
const parsedXhtml = useParseXhtml(element, textToDisplay);
17+
if (parsedXhtml) {
18+
return parsedXhtml.content;
19+
}
20+
21+
// parse markdown if found
22+
const markdownString = getMarkdownString(element);
23+
if (markdownString) {
24+
return (
25+
<ReactMarkdown
26+
components={{
27+
p: (props) => <span {...props} />
28+
}}>
29+
{markdownString}
30+
</ReactMarkdown>
31+
);
32+
}
33+
34+
// labelText is empty, return null
35+
if (!textToDisplay) {
36+
return null;
37+
}
38+
39+
const stylesString = structuredDataCapture.getStyle(element);
40+
const itemStyles = stylesString ? parseStyleToJs(stylesString) : {};
41+
42+
// parse regular text
43+
return <span style={itemStyles}>{textToDisplay}</span>;
44+
}
45+
46+
export default StyledText;

0 commit comments

Comments
 (0)