Skip to content

Commit 8b576ce

Browse files
DatetimeWidget - react-dates and rc-time-picker components (#7980)
Co-authored-by: Steve Piercy <web@stevepiercy.com>
1 parent 863b75f commit 8b576ce

3 files changed

Lines changed: 148 additions & 58 deletions

File tree

packages/volto/news/7980.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added required ARIA attributes to date and time inputs on `DatetimeWidget`. @Wagner3UB

packages/volto/src/components/manage/Widgets/DatetimeWidget.jsx

Lines changed: 92 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState, useEffect } from 'react';
1+
import React, { useState, useEffect, useRef } from 'react';
22
import PropTypes from 'prop-types';
33
import { defineMessages, useIntl } from 'react-intl';
44
import loadable from '@loadable/component';
@@ -85,16 +85,22 @@ const DatetimeWidgetComponent = (props) => {
8585
noPastDates: propNoPastDates,
8686
isDisabled,
8787
formData,
88+
required,
8889
} = props;
8990

9091
const intl = useIntl();
9192
const lang = intl.locale;
9293

94+
// timeInputRef: for aria-required (rc-time-picker has no aria props)
95+
const timeInputRef = useRef(null);
96+
9397
const [focused, setFocused] = useState(false);
9498
const [isDefault, setIsDefault] = useState(false);
9599

96100
const { SingleDatePicker } = reactDates;
97101

102+
const renderWidget = !(id === 'end' && formData?.open_end);
103+
98104
useEffect(() => {
99105
const parsedDateTime = parseDateTime(
100106
toBackendLang(lang),
@@ -107,11 +113,6 @@ const DatetimeWidgetComponent = (props) => {
107113
);
108114
}, [value, lang, moment]);
109115

110-
// If open_end is checked and this is the end field, don't render
111-
if (id === 'end' && formData?.open_end) {
112-
return null;
113-
}
114-
115116
const getInternalValue = () => {
116117
return parseDateTime(toBackendLang(lang), value, undefined, moment.default);
117118
};
@@ -165,68 +166,101 @@ const DatetimeWidgetComponent = (props) => {
165166
const datetime = getInternalValue();
166167
const isDateOnly = getDateOnly();
167168

169+
// aria-required for the time input (rc-time-picker is lazy-loaded,
170+
// so MutationObserver is needed to catch when it mounts its input)
171+
172+
// rc-time-picker does not have aria props, so we need to set aria-required
173+
// manually on the input element when the required prop changes
174+
175+
useEffect(() => {
176+
if (!renderWidget || isDateOnly) return;
177+
178+
function applyTimeAria() {
179+
const input = timeInputRef.current?.querySelector('input');
180+
if (!input) return;
181+
if (required) input.setAttribute('aria-required', 'true');
182+
else input.removeAttribute('aria-required');
183+
}
184+
185+
applyTimeAria();
186+
187+
const observer = new MutationObserver(applyTimeAria);
188+
if (timeInputRef.current) {
189+
observer.observe(timeInputRef.current, {
190+
childList: true,
191+
subtree: true,
192+
});
193+
}
194+
195+
return () => observer.disconnect();
196+
}, [required, isDateOnly, renderWidget]);
197+
168198
return (
169199
<FormFieldWrapper {...props}>
170-
<div className="date-time-widget-wrapper">
171-
<div
172-
className={cx('ui input date-input', {
173-
'default-date': isDefault,
174-
})}
175-
>
176-
<SingleDatePicker
177-
date={datetime}
178-
disabled={isDisabled}
179-
onDateChange={onDateChange}
180-
focused={focused}
181-
numberOfMonths={1}
182-
{...(noPastDates ? {} : { isOutsideRange: () => false })}
183-
onFocusChange={onFocusChange}
184-
noBorder
185-
displayFormat={moment.default
186-
.localeData(toBackendLang(lang))
187-
.longDateFormat('L')}
188-
navPrev={<PrevIcon />}
189-
navNext={<NextIcon />}
190-
id={`${id}-date`}
191-
placeholder={intl.formatMessage(messages.date)}
192-
/>
193-
</div>
194-
{!isDateOnly && (
200+
{renderWidget && (
201+
<div className="date-time-widget-wrapper">
195202
<div
196-
className={cx('ui input time-input', {
203+
className={cx('ui input date-input', {
197204
'default-date': isDefault,
198205
})}
199206
>
200-
<TimePicker
207+
<SingleDatePicker
208+
date={datetime}
201209
disabled={isDisabled}
202-
defaultValue={datetime}
203-
value={datetime}
204-
onChange={onTimeChange}
205-
allowEmpty={false}
206-
showSecond={false}
207-
use12Hours={lang === 'en'}
208-
id={`${id}-time`}
209-
format={moment.default
210+
onDateChange={onDateChange}
211+
focused={focused}
212+
numberOfMonths={1}
213+
{...(noPastDates ? {} : { isOutsideRange: () => false })}
214+
onFocusChange={onFocusChange}
215+
noBorder
216+
required={required}
217+
displayFormat={moment.default
210218
.localeData(toBackendLang(lang))
211-
.longDateFormat('LT')}
212-
placeholder={intl.formatMessage(messages.time)}
213-
focusOnOpen
214-
placement="bottomRight"
219+
.longDateFormat('L')}
220+
navPrev={<PrevIcon />}
221+
navNext={<NextIcon />}
222+
id={`${id}-date`}
223+
placeholder={intl.formatMessage(messages.date)}
215224
/>
216225
</div>
217-
)}
218-
{resettable && (
219-
<button
220-
type="button"
221-
disabled={isDisabled || !datetime}
222-
onClick={onResetDates}
223-
className="item ui noborder button"
224-
aria-label={intl.formatMessage(messages.clearDateTime)}
225-
>
226-
<Icon name={clearSVG} size="24px" className="close" />
227-
</button>
228-
)}
229-
</div>
226+
{!isDateOnly && (
227+
<div
228+
ref={timeInputRef}
229+
className={cx('ui input time-input', {
230+
'default-date': isDefault,
231+
})}
232+
>
233+
<TimePicker
234+
disabled={isDisabled}
235+
defaultValue={datetime}
236+
value={datetime}
237+
onChange={onTimeChange}
238+
allowEmpty={false}
239+
showSecond={false}
240+
use12Hours={lang === 'en'}
241+
id={`${id}-time`}
242+
format={moment.default
243+
.localeData(toBackendLang(lang))
244+
.longDateFormat('LT')}
245+
placeholder={intl.formatMessage(messages.time)}
246+
focusOnOpen
247+
placement="bottomRight"
248+
/>
249+
</div>
250+
)}
251+
{resettable && (
252+
<button
253+
type="button"
254+
disabled={isDisabled || !datetime}
255+
onClick={onResetDates}
256+
className="item ui noborder button"
257+
aria-label={intl.formatMessage(messages.clearDateTime)}
258+
>
259+
<Icon name={clearSVG} size="24px" className="close" />
260+
</button>
261+
)}
262+
</div>
263+
)}
230264
</FormFieldWrapper>
231265
);
232266
};

packages/volto/src/components/manage/Widgets/DatetimeWidget.test.jsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,58 @@ test('datetime widget converts UTC date and adapts to local datetime', async ()
6666
await waitFor(() => screen.getByPlaceholderText('Time'));
6767
expect(container).toMatchSnapshot();
6868
});
69+
70+
test('applies aria-required attribute to the date input when required prop is true', async () => {
71+
const store = mockStore({
72+
intl: {
73+
locale: 'en',
74+
messages: {},
75+
},
76+
});
77+
78+
const { container } = render(
79+
<Provider store={store}>
80+
<DatetimeWidget
81+
id="required-field"
82+
title="Required Field"
83+
onChange={() => {}}
84+
required={true}
85+
/>
86+
</Provider>,
87+
);
88+
89+
await waitFor(() => screen.getByPlaceholderText('Date'));
90+
91+
const dateInput = container.querySelector('.date-input input');
92+
93+
expect(dateInput).toHaveAttribute('required');
94+
});
95+
96+
test('applies aria-required attribute to the time input when required prop is true', async () => {
97+
const store = mockStore({
98+
intl: {
99+
locale: 'en',
100+
messages: {},
101+
},
102+
});
103+
104+
const { container } = render(
105+
<Provider store={store}>
106+
<DatetimeWidget
107+
id="required-field"
108+
title="Required Field"
109+
onChange={() => {}}
110+
required={true}
111+
/>
112+
</Provider>,
113+
);
114+
115+
// Wait for the lazy-loaded TimePicker to be mounted in the DOM
116+
await waitFor(() => screen.getByPlaceholderText('Time'));
117+
118+
// The rc-time-picker doesn't support aria-required natively,
119+
// so we verify if our MutationObserver/useEffect successfully injected it.
120+
const timeInput = container.querySelector('.time-input input');
121+
122+
expect(timeInput).toHaveAttribute('aria-required', 'true');
123+
});

0 commit comments

Comments
 (0)