Skip to content

Commit 9ba765f

Browse files
authored
Add aria-describedby to date field segments so help text and value is read (#2615)
1 parent 3471761 commit 9ba765f

File tree

5 files changed

+153
-18
lines changed

5 files changed

+153
-18
lines changed

packages/@react-aria/datepicker/src/useDateField.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ interface DateFieldAria {
3232
errorMessageProps: HTMLAttributes<HTMLElement>
3333
}
3434

35-
export const labelIds = new WeakMap<DatePickerFieldState, string>();
35+
export const labelIds = new WeakMap<DatePickerFieldState, {ariaLabelledBy: string, ariaDescribedBy: string}>();
3636

3737
export function useDateField<T extends DateValue>(props: DateFieldProps<T>, state: DatePickerFieldState, ref: RefObject<HTMLElement>): DateFieldAria {
3838
let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({
@@ -51,7 +51,13 @@ export function useDateField<T extends DateValue>(props: DateFieldProps<T>, stat
5151
let formatter = useDateFormatter(state.getFormatOptions({month: 'long'}));
5252
let descProps = useDescription(state.value ? formatter.format(state.dateValue) : null);
5353

54-
labelIds.set(state, fieldProps['aria-labelledby'] || fieldProps.id);
54+
let segmentLabelledBy = fieldProps['aria-labelledby'] || fieldProps.id;
55+
let describedBy = [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined;
56+
57+
labelIds.set(state, {
58+
ariaLabelledBy: segmentLabelledBy,
59+
ariaDescribedBy: describedBy
60+
});
5561

5662
return {
5763
labelProps: {
@@ -64,7 +70,7 @@ export function useDateField<T extends DateValue>(props: DateFieldProps<T>, stat
6470
fieldProps: mergeProps(fieldProps, descProps, groupProps, focusWithinProps, {
6571
role: 'group',
6672
'aria-disabled': props.isDisabled || undefined,
67-
'aria-describedby': [descProps['aria-describedby'], fieldProps['aria-describedby']].filter(Boolean).join(' ') || undefined
73+
'aria-describedby': describedBy
6874
}),
6975
descriptionProps,
7076
errorMessageProps

packages/@react-aria/datepicker/src/useDateSegment.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export function useDateSegment<T extends DateValue>(props: DatePickerProps<T> &
4444

4545
if (segment.type === 'month') {
4646
let monthTextValue = monthDateFormatter.format(state.dateValue);
47-
textValue = monthTextValue !== textValue ? `${textValue} - ${monthTextValue}` : monthTextValue;
47+
textValue = monthTextValue !== textValue ? `${textValue} ${monthTextValue}` : monthTextValue;
4848
} else if (segment.type === 'hour' || segment.type === 'dayPeriod') {
4949
textValue = hourDateFormatter.format(state.dateValue);
5050
}
@@ -328,7 +328,14 @@ export function useDateSegment<T extends DateValue>(props: DatePickerProps<T> &
328328
'aria-valuenow': null
329329
} : {};
330330

331-
let fieldLabelId = labelIds.get(state);
331+
let {ariaLabelledBy, ariaDescribedBy} = labelIds.get(state);
332+
333+
// Only apply aria-describedby to the first segment, unless the field is invalid. This avoids it being
334+
// read every time the user navigates to a new segment.
335+
let firstSegment = useMemo(() => state.segments.find(s => s.isEditable), [state.segments]);
336+
if (segment !== firstSegment && state.validationState !== 'invalid') {
337+
ariaDescribedBy = undefined;
338+
}
332339

333340
let id = useId(props.id);
334341
let isEditable = !props.isDisabled && !props.isReadOnly && segment.isEditable;
@@ -340,7 +347,8 @@ export function useDateSegment<T extends DateValue>(props: DatePickerProps<T> &
340347
// 'aria-haspopup': props['aria-haspopup'], // deprecated in ARIA 1.2
341348
'aria-invalid': state.validationState === 'invalid' ? 'true' : undefined,
342349
'aria-label': segment.type !== 'literal' ? displayNames.of(segment.type) : undefined,
343-
'aria-labelledby': `${fieldLabelId} ${id}`,
350+
'aria-labelledby': `${ariaLabelledBy} ${id}`,
351+
'aria-describedby': ariaDescribedBy,
344352
'aria-placeholder': segment.isPlaceholder ? segment.text : undefined,
345353
'aria-readonly': props.isReadOnly || !segment.isEditable ? 'true' : undefined,
346354
contentEditable: isEditable,

packages/@react-spectrum/datepicker/test/DateField.test.js

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,60 +84,94 @@ describe('DateField', function () {
8484
});
8585

8686
it('should support help text description', function () {
87-
let {getByRole} = render(<DateField label="Date" description="Help text" />);
87+
let {getByRole, getAllByRole} = render(<DateField label="Date" description="Help text" />);
8888

8989
let group = getByRole('group');
9090
expect(group).toHaveAttribute('aria-describedby');
9191

9292
let description = document.getElementById(group.getAttribute('aria-describedby'));
9393
expect(description).toHaveTextContent('Help text');
94+
95+
let segments = getAllByRole('spinbutton');
96+
expect(segments[0]).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
97+
98+
for (let segment of segments.slice(1)) {
99+
expect(segment).not.toHaveAttribute('aria-describedby');
100+
}
94101
});
95102

96103
it('should support error message', function () {
97-
let {getByRole} = render(<DateField label="Date" errorMessage="Error message" validationState="invalid" />);
104+
let {getByRole, getAllByRole} = render(<DateField label="Date" errorMessage="Error message" validationState="invalid" />);
98105

99106
let group = getByRole('group');
100107
expect(group).toHaveAttribute('aria-describedby');
101108

102109
let description = document.getElementById(group.getAttribute('aria-describedby'));
103110
expect(description).toHaveTextContent('Error message');
111+
112+
let segments = getAllByRole('spinbutton');
113+
for (let segment of segments) {
114+
expect(segment).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
115+
}
104116
});
105117

106118
it('should not display error message if not invalid', function () {
107-
let {getByRole} = render(<DateField label="Date" errorMessage="Error message" />);
119+
let {getByRole, getAllByRole} = render(<DateField label="Date" errorMessage="Error message" />);
108120

109121
let group = getByRole('group');
110122
expect(group).not.toHaveAttribute('aria-describedby');
123+
124+
let segments = getAllByRole('spinbutton');
125+
for (let segment of segments) {
126+
expect(segment).not.toHaveAttribute('aria-describedby');
127+
}
111128
});
112129

113130
it('should support help text with a value', function () {
114-
let {getByRole} = render(<DateField label="Date" description="Help text" value={new CalendarDate(2020, 2, 3)} />);
131+
let {getByRole, getAllByRole} = render(<DateField label="Date" description="Help text" value={new CalendarDate(2020, 2, 3)} />);
115132

116133
let group = getByRole('group');
117134
expect(group).toHaveAttribute('aria-describedby');
118135

119136
let description = group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
120137
expect(description).toBe('February 3, 2020 Help text');
138+
139+
let segments = getAllByRole('spinbutton');
140+
expect(segments[0]).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
141+
142+
for (let segment of segments.slice(1)) {
143+
expect(segment).not.toHaveAttribute('aria-describedby');
144+
}
121145
});
122146

123147
it('should support error message with a value', function () {
124-
let {getByRole} = render(<DateField label="Date" errorMessage="Error message" validationState="invalid" value={new CalendarDate(2020, 2, 3)} />);
148+
let {getByRole, getAllByRole} = render(<DateField label="Date" errorMessage="Error message" validationState="invalid" value={new CalendarDate(2020, 2, 3)} />);
125149

126150
let group = getByRole('group');
127151
expect(group).toHaveAttribute('aria-describedby');
128152

129153
let description = group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
130154
expect(description).toBe('February 3, 2020 Error message');
155+
156+
let segments = getAllByRole('spinbutton');
157+
for (let segment of segments) {
158+
expect(segment).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
159+
}
131160
});
132161

133162
it('should support format help text', function () {
134-
let {getByRole, getByText} = render(<DateField label="Date" showFormatHelpText />);
163+
let {getByRole, getByText, getAllByRole} = render(<DateField label="Date" showFormatHelpText />);
135164

136165
// Not needed in aria-described by because each segment has a label already, so this would be duplicative.
137166
let group = getByRole('group');
138167
expect(group).not.toHaveAttribute('aria-describedby');
139168

140169
expect(getByText('month / day / year')).toBeVisible();
170+
171+
let segments = getAllByRole('spinbutton');
172+
for (let segment of segments) {
173+
expect(segment).not.toHaveAttribute('aria-describedby');
174+
}
141175
});
142176
});
143177
});

packages/@react-spectrum/datepicker/test/DatePicker.test.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe('DatePicker', function () {
8080
expect(segments[0].textContent).toBe('2');
8181
expect(segments[0].getAttribute('aria-label')).toBe('month');
8282
expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
83-
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
83+
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
8484
expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
8585
expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
8686

@@ -113,7 +113,7 @@ describe('DatePicker', function () {
113113
expect(segments[0].textContent).toBe('2');
114114
expect(segments[0].getAttribute('aria-label')).toBe('month');
115115
expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
116-
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
116+
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
117117
expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
118118
expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
119119

@@ -504,6 +504,13 @@ describe('DatePicker', function () {
504504

505505
let description = document.getElementById(group.getAttribute('aria-describedby'));
506506
expect(description).toHaveTextContent('Help text');
507+
508+
let segments = getAllByRole('spinbutton');
509+
expect(segments[0]).toHaveAttribute('aria-describedby', description.id);
510+
511+
for (let segment of segments.slice(1)) {
512+
expect(segment).not.toHaveAttribute('aria-describedby');
513+
}
507514
});
508515

509516
it('should support error message', function () {
@@ -515,6 +522,11 @@ describe('DatePicker', function () {
515522

516523
let description = document.getElementById(group.getAttribute('aria-describedby'));
517524
expect(description).toHaveTextContent('Error message');
525+
526+
let segments = getAllByRole('spinbutton');
527+
for (let segment of segments) {
528+
expect(segment).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
529+
}
518530
});
519531

520532
it('should not display error message if not invalid', function () {
@@ -523,6 +535,11 @@ describe('DatePicker', function () {
523535
let [group, field] = getAllByRole('group');
524536
expect(group).not.toHaveAttribute('aria-describedby');
525537
expect(field).not.toHaveAttribute('aria-describedby');
538+
539+
let segments = getAllByRole('spinbutton');
540+
for (let segment of segments) {
541+
expect(segment).not.toHaveAttribute('aria-describedby');
542+
}
526543
});
527544

528545
it('should support help text with a value', function () {
@@ -534,6 +551,13 @@ describe('DatePicker', function () {
534551

535552
let description = group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
536553
expect(description).toBe('February 3, 2020 Help text');
554+
555+
let segments = getAllByRole('spinbutton');
556+
expect(segments[0]).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
557+
558+
for (let segment of segments.slice(1)) {
559+
expect(segment).not.toHaveAttribute('aria-describedby');
560+
}
537561
});
538562

539563
it('should support error message with a value', function () {
@@ -545,6 +569,11 @@ describe('DatePicker', function () {
545569

546570
let description = group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
547571
expect(description).toBe('February 3, 2020 Error message');
572+
573+
let segments = getAllByRole('spinbutton');
574+
for (let segment of segments) {
575+
expect(segment).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
576+
}
548577
});
549578

550579
it('should support format help text', function () {
@@ -556,6 +585,11 @@ describe('DatePicker', function () {
556585
expect(field).not.toHaveAttribute('aria-describedby');
557586

558587
expect(getByText('month / day / year')).toBeVisible();
588+
589+
let segments = getAllByRole('spinbutton');
590+
for (let segment of segments) {
591+
expect(segment).not.toHaveAttribute('aria-describedby');
592+
}
559593
});
560594
});
561595

packages/@react-spectrum/datepicker/test/DateRangePicker.test.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('DateRangePicker', function () {
9191
expect(segments[0].textContent).toBe('2');
9292
expect(segments[0].getAttribute('aria-label')).toBe('month');
9393
expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
94-
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
94+
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
9595
expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
9696
expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
9797

@@ -112,7 +112,7 @@ describe('DateRangePicker', function () {
112112
expect(segments[3].textContent).toBe('5');
113113
expect(segments[3].getAttribute('aria-label')).toBe('month');
114114
expect(segments[3].getAttribute('aria-valuenow')).toBe('5');
115-
expect(segments[3].getAttribute('aria-valuetext')).toBe('5 May');
115+
expect(segments[3].getAttribute('aria-valuetext')).toBe('5 May');
116116
expect(segments[3].getAttribute('aria-valuemin')).toBe('1');
117117
expect(segments[3].getAttribute('aria-valuemax')).toBe('12');
118118

@@ -145,7 +145,7 @@ describe('DateRangePicker', function () {
145145
expect(segments[0].textContent).toBe('2');
146146
expect(segments[0].getAttribute('aria-label')).toBe('month');
147147
expect(segments[0].getAttribute('aria-valuenow')).toBe('2');
148-
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
148+
expect(segments[0].getAttribute('aria-valuetext')).toBe('2 February');
149149
expect(segments[0].getAttribute('aria-valuemin')).toBe('1');
150150
expect(segments[0].getAttribute('aria-valuemax')).toBe('12');
151151

@@ -191,7 +191,7 @@ describe('DateRangePicker', function () {
191191
expect(segments[7].textContent).toBe('5');
192192
expect(segments[7].getAttribute('aria-label')).toBe('month');
193193
expect(segments[7].getAttribute('aria-valuenow')).toBe('5');
194-
expect(segments[7].getAttribute('aria-valuetext')).toBe('5 May');
194+
expect(segments[7].getAttribute('aria-valuetext')).toBe('5 May');
195195
expect(segments[7].getAttribute('aria-valuemin')).toBe('1');
196196
expect(segments[7].getAttribute('aria-valuemax')).toBe('12');
197197

@@ -619,6 +619,20 @@ describe('DateRangePicker', function () {
619619

620620
let description = document.getElementById(group.getAttribute('aria-describedby'));
621621
expect(description).toHaveTextContent('Help text');
622+
623+
let segments = within(startField).getAllByRole('spinbutton');
624+
expect(segments[0]).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
625+
626+
for (let segment of segments.slice(1)) {
627+
expect(segment).not.toHaveAttribute('aria-describedby');
628+
}
629+
630+
segments = within(endField).getAllByRole('spinbutton');
631+
expect(segments[0]).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
632+
633+
for (let segment of segments.slice(1)) {
634+
expect(segment).not.toHaveAttribute('aria-describedby');
635+
}
622636
});
623637

624638
it('should support error message', function () {
@@ -631,6 +645,11 @@ describe('DateRangePicker', function () {
631645

632646
let description = document.getElementById(group.getAttribute('aria-describedby'));
633647
expect(description).toHaveTextContent('Error message');
648+
649+
let segments = getAllByRole('spinbutton');
650+
for (let segment of segments) {
651+
expect(segment).toHaveAttribute('aria-describedby', group.getAttribute('aria-describedby'));
652+
}
634653
});
635654

636655
it('should not display error message if not invalid', function () {
@@ -640,6 +659,11 @@ describe('DateRangePicker', function () {
640659
expect(group).not.toHaveAttribute('aria-describedby');
641660
expect(startField).not.toHaveAttribute('aria-describedby');
642661
expect(endField).not.toHaveAttribute('aria-describedby');
662+
663+
let segments = getAllByRole('spinbutton');
664+
for (let segment of segments) {
665+
expect(segment).not.toHaveAttribute('aria-describedby');
666+
}
643667
});
644668

645669
it('should support help text with a value', function () {
@@ -656,8 +680,22 @@ describe('DateRangePicker', function () {
656680
description = startField.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
657681
expect(description).toBe('February 3, 2020 Help text');
658682

683+
let segments = within(startField).getAllByRole('spinbutton');
684+
expect(segments[0]).toHaveAttribute('aria-describedby', startField.getAttribute('aria-describedby'));
685+
686+
for (let segment of segments.slice(1)) {
687+
expect(segment).not.toHaveAttribute('aria-describedby');
688+
}
689+
659690
description = endField.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
660691
expect(description).toBe('February 10, 2020 Help text');
692+
693+
segments = within(endField).getAllByRole('spinbutton');
694+
expect(segments[0]).toHaveAttribute('aria-describedby', endField.getAttribute('aria-describedby'));
695+
696+
for (let segment of segments.slice(1)) {
697+
expect(segment).not.toHaveAttribute('aria-describedby');
698+
}
661699
});
662700

663701
it('should support error message with a value', function () {
@@ -674,8 +712,18 @@ describe('DateRangePicker', function () {
674712
description = startField.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
675713
expect(description).toBe('February 3, 2020 Error message');
676714

715+
let segments = within(startField).getAllByRole('spinbutton');
716+
for (let segment of segments) {
717+
expect(segment).toHaveAttribute('aria-describedby', startField.getAttribute('aria-describedby'));
718+
}
719+
677720
description = endField.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' ');
678721
expect(description).toBe('February 10, 2020 Error message');
722+
723+
segments = within(endField).getAllByRole('spinbutton');
724+
for (let segment of segments) {
725+
expect(segment).toHaveAttribute('aria-describedby', endField.getAttribute('aria-describedby'));
726+
}
679727
});
680728

681729
it('should support format help text', function () {
@@ -688,6 +736,11 @@ describe('DateRangePicker', function () {
688736
expect(endField).not.toHaveAttribute('aria-describedby');
689737

690738
expect(getByText('month / day / year')).toBeVisible();
739+
740+
let segments = getAllByRole('spinbutton');
741+
for (let segment of segments) {
742+
expect(segment).not.toHaveAttribute('aria-describedby');
743+
}
691744
});
692745
});
693746

0 commit comments

Comments
 (0)