From bea3cddbef07fc5a2cd3d7fa92edb25afd8b7cdb Mon Sep 17 00:00:00 2001 From: wjames111 Date: Wed, 16 Sep 2026 13:59:26 -0400 Subject: [PATCH 1/4] [MPDX-9996] Refresh NSO housing option wording and explain each option --- public/locales/en/translation.json | 12 ++-- .../NsoInformation/NsoDetails.test.tsx | 30 +++++++-- .../NsoInformation/NsoDetails.tsx | 27 +++++--- .../Shared/RadioQuestion.test.tsx | 25 ++++++++ .../Shared/RadioQuestion.tsx | 61 ++++++++++++++++--- .../functions/getLocalizedNsoHousing.test.ts | 57 +++++++++++++++-- src/lib/functions/getLocalizedNsoHousing.ts | 33 ++++++++-- 7 files changed, 212 insertions(+), 33 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 585a70ab55..621349c334 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -957,7 +957,6 @@ "Counseling": "Counseling", "Counseling that is not for the treatment of a medical condition": "Counseling that is not for the treatment of a medical condition", "Country": "Country", - "Couple in hotel/dorm room": "Couple in hotel/dorm room", "Couples": "Couples", "Create": "Create", "Create a new contact for \"{{ name }}\"": "Create a new contact for \"{{ name }}\"", @@ -1297,7 +1296,7 @@ "Fair Rental Value": "Fair Rental Value", "Falkland Islands (Malvinas)": "Falkland Islands (Malvinas)", "false": "false", - "Family in a hotel/room": "Family in a hotel/room", + "Family in hotel/suite dorm room": "Family in hotel/suite dorm room", "Family Size": "Family Size", "Family Size must be one of the options": "Family Size must be one of the options", "Family Status": "Family Status", @@ -1758,7 +1757,6 @@ "Loading donations graph": "Loading donations graph", "Loading MPD goals": "Loading MPD goals", "Loading...": "Loading...", - "Local / Commuting": "Local / Commuting", "Locale": "Locale", "Location": "Location", "Log": "Log", @@ -1805,6 +1803,7 @@ "Mark all as read": "Mark all as read", "Marked Do Not Ask": "Marked Do Not Ask", "Married": "Married", + "Married couple in hotel/suite dorm room": "Married couple in hotel/suite dorm room", "Marshall Islands": "Marshall Islands", "Martinique": "Martinique", "Mauritania": "Mauritania", @@ -2701,7 +2700,7 @@ "Share Account": "Share Account", "Share this ministry account with other team members": "Share this ministry account with other team members", "Share this organization with other team members": "Share this organization with other team members", - "Sharing 2 in hotel/dorm room": "Sharing 2 in hotel/dorm room", + "Sharing 2 in hotel/suite dorm room": "Sharing 2 in hotel/suite dorm room", "Short Term Assignment": "Short Term Assignment", "Show {{amount}} More": "Show {{amount}} More", "Show {{partnerCount}} Partners": "Show {{partnerCount}} Partners", @@ -3282,6 +3281,7 @@ "View/Edit {{name}}": "View/Edit {{name}}", "Virgin Islands, British": "Virgin Islands, British", "Virgin Islands, U.S.": "Virgin Islands, U.S.", + "Virtual / Local / Commuting": "Virtual / Local / Commuting", "Voluntary 403b Retirement Plan": "Voluntary 403b Retirement Plan", "Wallis and Futuna": "Wallis and Futuna", "Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.": "Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.", @@ -3394,11 +3394,13 @@ "You have a Board approved Maximum Allowable Salary (CAP) and your Additional Salary Request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.": "You have a Board approved Maximum Allowable Salary (CAP) and your Additional Salary Request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.", "You have a Board approved Maximum Allowable Salary (CAP) and your salary request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.": "You have a Board approved Maximum Allowable Salary (CAP) and your salary request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.", "You have a previously approved Maximum Allowable Salary (CAP). Any adjustment that may exceed this cap must be submitted for further approval.": "You have a previously approved Maximum Allowable Salary (CAP). Any adjustment that may exceed this cap must be submitted for further approval.", + "You have a roommate, or your own bedroom in a shared suite.": "You have a roommate, or your own bedroom in a shared suite.", "You have a saved Additional Salary Request in progress that has not been submitted.": "You have a saved Additional Salary Request in progress that has not been submitted.", "You have no designation accounts": "You have no designation accounts", "You have no expected donations this month": "You have no expected donations this month", "You have no Google Contact groups/labels": "You have no Google Contact groups/labels", "You have no responsibility centers.": "You have no responsibility centers.", + "You have no roommate and no suitemates.": "You have no roommate and no suitemates.", "You have reached the end of the goal calculator.": "You have reached the end of the goal calculator.", "You have received no donations in the last fourteen months": "You have received no donations in the last fourteen months", "You haven't connected a Google account yet": "You haven't connected a Google account yet", @@ -3419,7 +3421,9 @@ "You'll also need to search by contact name, email address, phone number, or partner number.": "You'll also need to search by contact name, email address, phone number, or partner number.", "You're all done": "You're all done", "You're all set!": "You're all set!", + "You're attending virtually or commuting daily and not using NSO housing.": "You're attending virtually or commuting daily and not using NSO housing.", "You're currently being logged out.": "You're currently being logged out.", + "You're staying together as a couple or family.": "You're staying together as a couple or family.", "You've successfully submitted your {{formTitle}}!": "You've successfully submitted your {{formTitle}}!", "You've successfully submitted your Salary Calculation Form!": "You've successfully submitted your Salary Calculation Form!", "You've successfully updated your {{formTitle}}!": "You've successfully updated your {{formTitle}}!", diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx index 9f058764dd..147fd8ebb8 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx @@ -28,23 +28,43 @@ describe('NsoDetails', () => { getByRole('radio', { name: 'Single in hotel/dorm room' }), ).toBeInTheDocument(); expect( - getByRole('radio', { name: 'Sharing 2 in hotel/dorm room' }), + getByRole('radio', { name: 'Sharing 2 in hotel/suite dorm room' }), ).toBeInTheDocument(); expect( - getByRole('radio', { name: 'Couple in hotel/dorm room' }), + getByRole('radio', { name: 'Married couple in hotel/suite dorm room' }), ).toBeInTheDocument(); expect( - getByRole('radio', { name: 'Family in a hotel/room' }), + getByRole('radio', { name: 'Family in hotel/suite dorm room' }), ).toBeInTheDocument(); expect( - getByRole('radio', { name: 'Local / Commuting' }), + getByRole('radio', { name: 'Virtual / Local / Commuting' }), + ).toBeInTheDocument(); + }); + + it('explains each housing option under its label', () => { + const { getByRole, getByText } = render(); + + expect( + getByText('You have no roommate and no suitemates.'), + ).toHaveAttribute( + 'id', + getByRole('radio', { name: 'Single in hotel/dorm room' }).getAttribute( + 'aria-describedby', + ), + ); + expect( + getByText( + "You're attending virtually or commuting daily and not using NSO housing.", + ), ).toBeInTheDocument(); }); it('saves the housing enum constant', async () => { const { getByRole } = render(); - userEvent.click(getByRole('radio', { name: 'Family in a hotel/room' })); + userEvent.click( + getByRole('radio', { name: 'Family in hotel/suite dorm room' }), + ); await waitFor(() => expect(mutationSpy).toHaveGraphqlOperation( diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx index 68a42ebb58..0782d6ee7a 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx @@ -3,6 +3,11 @@ import { Stack } from '@mui/material'; import { TFunction, useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { CurrencyAdornment } from 'src/components/HrTools/Shared/Adornments'; +import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated'; +import { + getLocalizedNsoHousing, + getLocalizedNsoHousingDescription, +} from 'src/lib/functions/getLocalizedNsoHousing'; import { NumberQuestion } from '../Shared/NumberQuestion'; import { RadioOption, RadioQuestion } from '../Shared/RadioQuestion'; import { getAmountSchema } from '../Shared/helpers/getAmountSchema'; @@ -24,13 +29,21 @@ export const NsoDetails: React.FC = () => { const schema = useMemo(() => getNsoDetailsSchema(t), [t]); - const housingOptions: RadioOption[] = [ - { value: 'SINGLE_ROOM', label: t('Single in hotel/dorm room') }, - { value: 'SHARED_ROOM', label: t('Sharing 2 in hotel/dorm room') }, - { value: 'COUPLE_ROOM', label: t('Couple in hotel/dorm room') }, - { value: 'FAMILY_ROOM', label: t('Family in a hotel/room') }, - { value: 'LOCAL_COMMUTING', label: t('Local / Commuting') }, - ]; + const housingOptions = useMemo( + () => + [ + NewStaffQuestionnaireNsoHousingEnum.SingleRoom, + NewStaffQuestionnaireNsoHousingEnum.SharedRoom, + NewStaffQuestionnaireNsoHousingEnum.CoupleRoom, + NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, + NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, + ].map((value) => ({ + value, + label: getLocalizedNsoHousing(t, value), + description: getLocalizedNsoHousingDescription(t, value), + })), + [t], + ); const sessionOptions: RadioOption[] = [ { value: 'IBS_AND_NSO', label: t('IBS and NSO') }, diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx index 37de9b32a2..a2c13f6758 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx @@ -77,6 +77,31 @@ describe('RadioQuestion', () => { ); }); + it('shows an option description under its label and links it to the radio', () => { + const { getByRole, getByText } = render( + + + , + ); + + const radio = getByRole('radio', { name: 'Option A' }); + expect(getByText('The first choice')).toHaveAttribute( + 'id', + radio.getAttribute('aria-describedby'), + ); + expect(getByRole('radio', { name: 'Option B' })).not.toHaveAttribute( + 'aria-describedby', + ); + }); + it('lays options out in a row when row is set', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx index 6bda031648..3663895fa3 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx @@ -1,5 +1,11 @@ -import React from 'react'; -import { FormControlLabel, Radio, RadioGroup } from '@mui/material'; +import React, { useId } from 'react'; +import { + Box, + FormControlLabel, + Radio, + RadioGroup, + Typography, +} from '@mui/material'; import * as yup from 'yup'; import { LabeledField } from './LabeledField'; import { @@ -10,6 +16,8 @@ import { export interface RadioOption { value: string; label: string; + /** Shown under the label in muted text to clarify what the option means. */ + description?: string; } interface RadioQuestionProps { @@ -21,6 +29,48 @@ interface RadioQuestionProps { row?: boolean; } +// Matches the Radio's own padding so the first label line lines up with the circle. +const radioPadding = '9px'; + +const RadioQuestionOption: React.FC<{ option: RadioOption }> = ({ option }) => { + const labelId = useId(); + const descriptionId = useId(); + + return ( + + } + disableTypography + label={ + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + } + sx={{ alignItems: 'flex-start' }} + /> + ); +}; + /** * A single required radio question wired to {@link useQuestionnaireAutoSave}. Saves on change and * surfaces the schema's validation message as helper text while empty. @@ -49,12 +99,7 @@ export const RadioQuestion: React.FC = ({ {...fieldProps} > {options.map((option) => ( - } - label={option.label} - /> + ))} )} diff --git a/src/lib/functions/getLocalizedNsoHousing.test.ts b/src/lib/functions/getLocalizedNsoHousing.test.ts index a724ccf57c..cdeba9d5ba 100644 --- a/src/lib/functions/getLocalizedNsoHousing.test.ts +++ b/src/lib/functions/getLocalizedNsoHousing.test.ts @@ -1,5 +1,8 @@ import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated'; -import { getLocalizedNsoHousing } from './getLocalizedNsoHousing'; +import { + getLocalizedNsoHousing, + getLocalizedNsoHousingDescription, +} from './getLocalizedNsoHousing'; const t = (key: string) => key; @@ -11,14 +14,20 @@ describe('getLocalizedNsoHousing', () => { ], [ NewStaffQuestionnaireNsoHousingEnum.SharedRoom, - 'Sharing 2 in hotel/dorm room', + 'Sharing 2 in hotel/suite dorm room', ], [ NewStaffQuestionnaireNsoHousingEnum.CoupleRoom, - 'Couple in hotel/dorm room', + 'Married couple in hotel/suite dorm room', + ], + [ + NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, + 'Family in hotel/suite dorm room', + ], + [ + NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, + 'Virtual / Local / Commuting', ], - [NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, 'Family in a hotel/room'], - [NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, 'Local / Commuting'], ])('maps %s to "%s"', (housing, expected) => { expect(getLocalizedNsoHousing(t, housing)).toBe(expected); }); @@ -34,3 +43,41 @@ describe('getLocalizedNsoHousing', () => { ).toBe(''); }); }); + +describe('getLocalizedNsoHousingDescription', () => { + it.each([ + [ + NewStaffQuestionnaireNsoHousingEnum.SingleRoom, + 'You have no roommate and no suitemates.', + ], + [ + NewStaffQuestionnaireNsoHousingEnum.SharedRoom, + 'You have a roommate, or your own bedroom in a shared suite.', + ], + [ + NewStaffQuestionnaireNsoHousingEnum.CoupleRoom, + "You're staying together as a couple or family.", + ], + [ + NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, + "You're staying together as a couple or family.", + ], + [ + NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, + "You're attending virtually or commuting daily and not using NSO housing.", + ], + ])('describes %s as "%s"', (housing, expected) => { + expect(getLocalizedNsoHousingDescription(t, housing)).toBe(expected); + }); + + it('returns an empty string for null, undefined, or an unrecognized value', () => { + expect(getLocalizedNsoHousingDescription(t, null)).toBe(''); + expect(getLocalizedNsoHousingDescription(t, undefined)).toBe(''); + expect( + getLocalizedNsoHousingDescription( + t, + 'nonsense' as NewStaffQuestionnaireNsoHousingEnum, + ), + ).toBe(''); + }); +}); diff --git a/src/lib/functions/getLocalizedNsoHousing.ts b/src/lib/functions/getLocalizedNsoHousing.ts index 9260d14374..808544dd6f 100644 --- a/src/lib/functions/getLocalizedNsoHousing.ts +++ b/src/lib/functions/getLocalizedNsoHousing.ts @@ -13,13 +13,38 @@ export const getLocalizedNsoHousing = ( case NewStaffQuestionnaireNsoHousingEnum.SingleRoom: return t('Single in hotel/dorm room'); case NewStaffQuestionnaireNsoHousingEnum.SharedRoom: - return t('Sharing 2 in hotel/dorm room'); + return t('Sharing 2 in hotel/suite dorm room'); case NewStaffQuestionnaireNsoHousingEnum.CoupleRoom: - return t('Couple in hotel/dorm room'); + return t('Married couple in hotel/suite dorm room'); case NewStaffQuestionnaireNsoHousingEnum.FamilyRoom: - return t('Family in a hotel/room'); + return t('Family in hotel/suite dorm room'); case NewStaffQuestionnaireNsoHousingEnum.LocalCommuting: - return t('Local / Commuting'); + return t('Virtual / Local / Commuting'); + default: + return ''; + } +}; + +/** + * Explains what each New Staff Orientation housing option means, shown under + * the option's label to help new staff pick the right one. + */ +export const getLocalizedNsoHousingDescription = ( + t: TFunction, + housing: NewStaffQuestionnaireNsoHousingEnum | null | undefined, +): string => { + switch (housing) { + case NewStaffQuestionnaireNsoHousingEnum.SingleRoom: + return t('You have no roommate and no suitemates.'); + case NewStaffQuestionnaireNsoHousingEnum.SharedRoom: + return t('You have a roommate, or your own bedroom in a shared suite.'); + case NewStaffQuestionnaireNsoHousingEnum.CoupleRoom: + case NewStaffQuestionnaireNsoHousingEnum.FamilyRoom: + return t("You're staying together as a couple or family."); + case NewStaffQuestionnaireNsoHousingEnum.LocalCommuting: + return t( + "You're attending virtually or commuting daily and not using NSO housing.", + ); default: return ''; } From 95df6c3fe3fcfc8696c6a673575600fdb6068a33 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 17 Sep 2026 12:43:40 -0400 Subject: [PATCH 2/4] [MPDX-9996] Assert every housing option's description in the questionnaire test --- .../NsoInformation/NsoDetails.test.tsx | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx index 147fd8ebb8..ad5de1ff15 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx @@ -41,23 +41,37 @@ describe('NsoDetails', () => { ).toBeInTheDocument(); }); - it('explains each housing option under its label', () => { - const { getByRole, getByText } = render(); - - expect( - getByText('You have no roommate and no suitemates.'), - ).toHaveAttribute( - 'id', - getByRole('radio', { name: 'Single in hotel/dorm room' }).getAttribute( + it.each([ + ['Single in hotel/dorm room', 'You have no roommate and no suitemates.'], + [ + 'Sharing 2 in hotel/suite dorm room', + 'You have a roommate, or your own bedroom in a shared suite.', + ], + [ + 'Married couple in hotel/suite dorm room', + "You're staying together as a couple or family.", + ], + [ + 'Family in hotel/suite dorm room', + "You're staying together as a couple or family.", + ], + [ + 'Virtual / Local / Commuting', + "You're attending virtually or commuting daily and not using NSO housing.", + ], + ])( + 'explains the "%s" housing option under its label', + (label, description) => { + const { getByRole } = render(); + + const descriptionId = getByRole('radio', { name: label }).getAttribute( 'aria-describedby', - ), - ); - expect( - getByText( - "You're attending virtually or commuting daily and not using NSO housing.", - ), - ).toBeInTheDocument(); - }); + ); + expect(document.getElementById(descriptionId ?? '')).toHaveTextContent( + description, + ); + }, + ); it('saves the housing enum constant', async () => { const { getByRole } = render(); From 547a9ff41a05bc36d0db55f4eb41b7526f2b729c Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 17 Sep 2026 12:43:47 -0400 Subject: [PATCH 3/4] [MPDX-9996] Build NSO session options from the shared label helper --- .../NsoInformation/NsoDetails.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx index 0782d6ee7a..3ae92f545d 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx @@ -3,11 +3,15 @@ import { Stack } from '@mui/material'; import { TFunction, useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { CurrencyAdornment } from 'src/components/HrTools/Shared/Adornments'; -import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated'; +import { + NewStaffQuestionnaireNsoHousingEnum, + NewStaffQuestionnaireNsoSessionsEnum, +} from 'src/graphql/types.generated'; import { getLocalizedNsoHousing, getLocalizedNsoHousingDescription, } from 'src/lib/functions/getLocalizedNsoHousing'; +import { getLocalizedNsoSessions } from 'src/lib/functions/getLocalizedNsoSessions'; import { NumberQuestion } from '../Shared/NumberQuestion'; import { RadioOption, RadioQuestion } from '../Shared/RadioQuestion'; import { getAmountSchema } from '../Shared/helpers/getAmountSchema'; @@ -45,10 +49,14 @@ export const NsoDetails: React.FC = () => { [t], ); - const sessionOptions: RadioOption[] = [ - { value: 'IBS_AND_NSO', label: t('IBS and NSO') }, - { value: 'NSO', label: t('NSO') }, - ]; + const sessionOptions = useMemo( + () => + [ + NewStaffQuestionnaireNsoSessionsEnum.IbsAndNso, + NewStaffQuestionnaireNsoSessionsEnum.Nso, + ].map((value) => ({ value, label: getLocalizedNsoSessions(t, value) })), + [t], + ); return ( From a001c86ea3c47e69bd0672b028ef895bdf413016 Mon Sep 17 00:00:00 2001 From: wjames111 Date: Thu, 17 Sep 2026 12:45:01 -0400 Subject: [PATCH 4/4] [MPDX-9996] Share the described radio option between the questionnaire and goal dialog --- .../Shared/RadioQuestion.tsx | 59 ++------------- .../GoalsList/CreateGoalDialog.tsx | 56 +++----------- .../Shared/DescribedRadioOption.test.tsx | 36 +++++++++ .../HrTools/Shared/DescribedRadioOption.tsx | 74 +++++++++++++++++++ 4 files changed, 129 insertions(+), 96 deletions(-) create mode 100644 src/components/HrTools/Shared/DescribedRadioOption.test.tsx create mode 100644 src/components/HrTools/Shared/DescribedRadioOption.tsx diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx index 3663895fa3..302a9c6fa7 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx @@ -1,12 +1,7 @@ -import React, { useId } from 'react'; -import { - Box, - FormControlLabel, - Radio, - RadioGroup, - Typography, -} from '@mui/material'; +import React from 'react'; +import { RadioGroup } from '@mui/material'; import * as yup from 'yup'; +import { DescribedRadioOption } from 'src/components/HrTools/Shared/DescribedRadioOption'; import { LabeledField } from './LabeledField'; import { QuestionnaireField, @@ -29,48 +24,6 @@ interface RadioQuestionProps { row?: boolean; } -// Matches the Radio's own padding so the first label line lines up with the circle. -const radioPadding = '9px'; - -const RadioQuestionOption: React.FC<{ option: RadioOption }> = ({ option }) => { - const labelId = useId(); - const descriptionId = useId(); - - return ( - - } - disableTypography - label={ - - - {option.label} - - {option.description && ( - - {option.description} - - )} - - } - sx={{ alignItems: 'flex-start' }} - /> - ); -}; - /** * A single required radio question wired to {@link useQuestionnaireAutoSave}. Saves on change and * surfaces the schema's validation message as helper text while empty. @@ -88,18 +41,20 @@ export const RadioQuestion: React.FC = ({ saveOnChange: true, }); + const hasDescriptions = options.some((option) => option.description); + return ( {(aria) => ( {options.map((option) => ( - + ))} )} diff --git a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx index 327337b9b3..bcdd3f1f69 100644 --- a/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx +++ b/src/components/HrTools/PdsGoalCalculator/GoalsList/CreateGoalDialog.tsx @@ -7,18 +7,16 @@ import { DialogContent, DialogTitle, FormControl, - FormControlLabel, FormLabel, - Radio, RadioGroup, SxProps, Theme, - Typography, } from '@mui/material'; import { visuallyHidden } from '@mui/utils'; import { Formik, FormikProps } from 'formik'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; +import { DescribedRadioOption } from 'src/components/HrTools/Shared/DescribedRadioOption'; import { DesignationSupportFormType } from 'src/graphql/types.generated'; export interface CreateGoalDialogProps { @@ -110,48 +108,18 @@ export const CreateGoalDialog: React.FC = ({ name="formType" value={values.formType} onChange={handleChange} + sx={{ gap: 2 }} > - {formTypeOptions.map( - ({ value, title, description }, index) => ( - - } - label={ - <> - - {title} - - - {description} - - - } - sx={{ - alignItems: 'flex-start', - mb: index < formTypeOptions.length - 1 ? 2 : 0, - }} - /> - ), - )} + {formTypeOptions.map(({ value, title, description }) => ( + + ))} diff --git a/src/components/HrTools/Shared/DescribedRadioOption.test.tsx b/src/components/HrTools/Shared/DescribedRadioOption.test.tsx new file mode 100644 index 0000000000..8c0cc38aad --- /dev/null +++ b/src/components/HrTools/Shared/DescribedRadioOption.test.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { RadioGroup } from '@mui/material'; +import { render } from '@testing-library/react'; +import { DescribedRadioOption } from './DescribedRadioOption'; + +describe('DescribedRadioOption', () => { + it('names the radio by its label alone and links the description via aria-describedby', () => { + const { getByRole, getByText } = render( + + + , + ); + + const radio = getByRole('radio', { name: 'Option A' }); + expect(getByText('The first choice')).toHaveAttribute( + 'id', + radio.getAttribute('aria-describedby'), + ); + }); + + it('omits aria-describedby when there is no description', () => { + const { getByRole } = render( + + + , + ); + + expect(getByRole('radio', { name: 'Option B' })).not.toHaveAttribute( + 'aria-describedby', + ); + }); +}); diff --git a/src/components/HrTools/Shared/DescribedRadioOption.tsx b/src/components/HrTools/Shared/DescribedRadioOption.tsx new file mode 100644 index 0000000000..4e42211641 --- /dev/null +++ b/src/components/HrTools/Shared/DescribedRadioOption.tsx @@ -0,0 +1,74 @@ +import React, { useId } from 'react'; +import { + Box, + FormControlLabel, + Radio, + Typography, + TypographyProps, +} from '@mui/material'; + +interface DescribedRadioOptionProps { + value: string; + label: string; + /** Shown under the label in muted text to clarify what the option means. */ + description?: string; + labelVariant?: TypographyProps['variant']; + descriptionVariant?: TypographyProps['variant']; +} + +// Matches the Radio's own padding so the first label line lines up with the circle. +const radioPadding = '9px'; + +/** + * A radio option whose accessible name is the label alone, with the optional description exposed + * through aria-describedby. Must be rendered inside a RadioGroup. + */ +export const DescribedRadioOption: React.FC = ({ + value, + label, + description, + labelVariant = 'body1', + descriptionVariant = 'caption', +}) => { + const labelId = useId(); + const descriptionId = useId(); + + return ( + + } + disableTypography + label={ + + + {label} + + {description && ( + + {description} + + )} + + } + sx={{ alignItems: 'flex-start' }} + /> + ); +};