Skip to content

Commit f9161e9

Browse files
authored
refactor(experience): improve a11y support for the profile field inputs (#7691)
1 parent 94c9825 commit f9161e9

File tree

8 files changed

+147
-92
lines changed

8 files changed

+147
-92
lines changed

packages/experience/src/components/InputFields/DateField/index.module.scss

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,34 @@
1717
transition-duration: 0.2s;
1818
background: inherit;
1919

20+
.clickOverlay {
21+
position: absolute;
22+
inset: 0;
23+
cursor: text;
24+
25+
span {
26+
opacity: 0%;
27+
}
28+
29+
&.disabled {
30+
display: none;
31+
}
32+
}
33+
2034
&:focus-visible {
2135
outline: none;
2236
}
2337

2438
input {
25-
border: none;
2639
background: inherit;
2740
color: var(--color-type-primary);
2841
/* stylelint-disable-next-line property-no-unknown */
2942
field-sizing: content;
43+
opacity: 0%;
44+
45+
&.active {
46+
opacity: 100%;
47+
}
3048

3149
&::placeholder {
3250
color: var(--color-type-secondary);
@@ -39,6 +57,11 @@
3957

4058
.separator {
4159
color: var(--color-type-secondary);
60+
opacity: 0%;
61+
62+
&.active {
63+
opacity: 100%;
64+
}
4265
}
4366
}
4467

packages/experience/src/components/InputFields/DateField/index.test.tsx

Lines changed: 66 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useState } from 'react';
44

55
import DateField from '.';
66

7-
// Mock i18n hook (only label optional usage); return label directly
87
jest.mock('react-i18next', () => ({
98
useTranslation: () => ({
109
t: (key: string, options?: Record<string, string>) => options?.label ?? key,
@@ -29,16 +28,17 @@ const Controlled = (props: React.ComponentProps<typeof DateField>) => {
2928

3029
describe('DateField Component', () => {
3130
test('render US format placeholders and separator', () => {
32-
const { container } = render(<DateField dateFormat={SupportedDateFormat.US} label="dob" />);
31+
const { container } = render(
32+
<DateField dateFormat={SupportedDateFormat.US} label="Date of birth" />
33+
);
3334

34-
// Focus wrapper to activate inputs
35-
const wrapper = container.querySelector('[role="button"]');
36-
expect(wrapper).not.toBeNull();
37-
if (wrapper) {
38-
act(() => {
39-
fireEvent.click(wrapper);
40-
});
41-
}
35+
const labelNode = Array.from(container.querySelectorAll('*')).find(
36+
(element) => element.textContent === 'Date of birth'
37+
);
38+
expect(labelNode).toBeTruthy();
39+
act(() => {
40+
fireEvent.click(labelNode!);
41+
});
4242

4343
const inputs = Array.from(container.querySelectorAll('input'));
4444
expect(inputs).toHaveLength(3);
@@ -56,14 +56,18 @@ describe('DateField Component', () => {
5656
test('typing fills each part and produces final date (controlled, 2023-08-20)', () => {
5757
const handleChange = jest.fn();
5858
const { container } = render(
59-
<Controlled dateFormat={SupportedDateFormat.US} onChange={handleChange} />
59+
<Controlled
60+
dateFormat={SupportedDateFormat.US}
61+
label="Date of birth"
62+
onChange={handleChange}
63+
/>
6064
);
61-
const wrapper = container.querySelector('[role="button"]');
62-
if (wrapper) {
63-
act(() => {
64-
fireEvent.click(wrapper);
65-
});
66-
}
65+
const labelNode = Array.from(container.querySelectorAll('*')).find(
66+
(element) => element.textContent === 'Date of birth'
67+
);
68+
act(() => {
69+
fireEvent.click(labelNode!);
70+
});
6771
const inputs = Array.from(container.querySelectorAll('input'));
6872

6973
act(() => {
@@ -80,14 +84,19 @@ describe('DateField Component', () => {
8084
test('backspace clears previous field when empty', () => {
8185
const handleChange = jest.fn();
8286
const { container } = render(
83-
<Controlled dateFormat={SupportedDateFormat.US} value="08/20/2023" onChange={handleChange} />
87+
<Controlled
88+
dateFormat={SupportedDateFormat.US}
89+
value="08/20/2023"
90+
label="Date of birth"
91+
onChange={handleChange}
92+
/>
8493
);
85-
const wrapper = container.querySelector('[role="button"]');
86-
if (wrapper) {
87-
act(() => {
88-
fireEvent.click(wrapper);
89-
});
90-
}
94+
const labelNode = Array.from(container.querySelectorAll('*')).find(
95+
(element) => element.textContent === 'Date of birth'
96+
);
97+
act(() => {
98+
fireEvent.click(labelNode!);
99+
});
91100
const inputs = Array.from(container.querySelectorAll('input'));
92101

93102
// Focus last input and clear it with backspace until it moves to previous
@@ -116,14 +125,18 @@ describe('DateField Component', () => {
116125
test('paste distributes digits across inputs', () => {
117126
const handleChange = jest.fn();
118127
const { container } = render(
119-
<DateField dateFormat={SupportedDateFormat.US} onChange={handleChange} />
128+
<DateField
129+
dateFormat={SupportedDateFormat.US}
130+
label="Date of birth"
131+
onChange={handleChange}
132+
/>
120133
);
121-
const wrapper = container.querySelector('[role="button"]');
122-
if (wrapper) {
123-
act(() => {
124-
fireEvent.click(wrapper);
125-
});
126-
}
134+
const labelNode = Array.from(container.querySelectorAll('*')).find(
135+
(element) => element.textContent === 'Date of birth'
136+
);
137+
act(() => {
138+
fireEvent.click(labelNode!);
139+
});
127140
const inputs = Array.from(container.querySelectorAll('input'));
128141

129142
act(() => {
@@ -155,14 +168,18 @@ describe('DateField Component', () => {
155168
test('UK format placeholders and value assembly', () => {
156169
const handleChange = jest.fn();
157170
const { container } = render(
158-
<Controlled dateFormat={SupportedDateFormat.UK} onChange={handleChange} />
171+
<Controlled
172+
dateFormat={SupportedDateFormat.UK}
173+
label="Date of birth"
174+
onChange={handleChange}
175+
/>
159176
);
160-
const wrapper = container.querySelector('[role="button"]');
161-
if (wrapper) {
162-
act(() => {
163-
fireEvent.click(wrapper);
164-
});
165-
}
177+
const labelNode = Array.from(container.querySelectorAll('*')).find(
178+
(element) => element.textContent === 'Date of birth'
179+
);
180+
act(() => {
181+
fireEvent.click(labelNode!);
182+
});
166183
const inputs = Array.from(container.querySelectorAll('input'));
167184
expect(inputs[0]?.getAttribute('placeholder')).toBe('DD');
168185
expect(inputs[1]?.getAttribute('placeholder')).toBe('MM');
@@ -178,14 +195,18 @@ describe('DateField Component', () => {
178195
test('ISO format placeholders, separator and value assembly', () => {
179196
const handleChange = jest.fn();
180197
const { container } = render(
181-
<Controlled dateFormat={SupportedDateFormat.ISO} onChange={handleChange} />
198+
<Controlled
199+
dateFormat={SupportedDateFormat.ISO}
200+
label="Date of birth"
201+
onChange={handleChange}
202+
/>
182203
);
183-
const wrapper = container.querySelector('[role="button"]');
184-
if (wrapper) {
185-
act(() => {
186-
fireEvent.click(wrapper);
187-
});
188-
}
204+
const labelNode = Array.from(container.querySelectorAll('*')).find(
205+
(element) => element.textContent === 'Date of birth'
206+
);
207+
act(() => {
208+
fireEvent.click(labelNode!);
209+
});
189210
const inputs = Array.from(container.querySelectorAll('input'));
190211
expect(inputs[0]?.getAttribute('placeholder')).toBe('YYYY');
191212
expect(inputs[1]?.getAttribute('placeholder')).toBe('MM');

packages/experience/src/components/InputFields/DateField/index.tsx

Lines changed: 45 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
import { SupportedDateFormat } from '@logto/schemas';
2-
import { condString } from '@silverhand/essentials';
2+
import { cond, condString } from '@silverhand/essentials';
33
import classNames from 'classnames';
44
import type { FormEventHandler, KeyboardEventHandler, ClipboardEventHandler } from 'react';
5-
import { useCallback, useState, useRef, useMemo, Fragment, useEffect } from 'react';
5+
import { useCallback, useState, useRef, useMemo, Fragment, useEffect, useId } from 'react';
66
import { useTranslation } from 'react-i18next';
77

8-
import { onKeyDownHandler as a11yKeyDownHandler } from '@/utils/a11y';
9-
108
import InputField from '../InputField';
119
import NotchedBorder from '../InputField/NotchedBorder';
1210

1311
import styles from './index.module.scss';
1412

1513
type Props = {
1614
readonly className?: string;
15+
readonly name?: string;
1716
readonly dateFormat?: string;
1817
readonly description?: string;
1918
readonly errorMessage?: string;
@@ -75,6 +74,7 @@ const DateField = (props: Props) => {
7574

7675
const [isFocused, setIsFocused] = useState(false);
7776
const isActive = isFocused || !!value;
77+
const firstInputId = useId();
7878
const inputReferences = useRef<Array<HTMLInputElement | undefined>>([]);
7979
const containerRef = useRef<HTMLDivElement>(null);
8080
const isSupportedDateFormat =
@@ -269,58 +269,61 @@ const DateField = (props: Props) => {
269269
<div ref={containerRef} className={classNames(styles.dateFieldContainer, className)}>
270270
<div
271271
className={styles.dateInputWrapper}
272-
tabIndex={0}
273-
role="button"
274-
onKeyDown={a11yKeyDownHandler(() => {
275-
setIsFocused(true);
276-
})}
277-
onClick={() => {
278-
setIsFocused(true);
279-
}}
272+
// Rely on bubbling onBlur from inner inputs to detect leaving the whole group
280273
onBlur={(event) => {
281-
// Check if the focus is moving to an element outside the dateFieldContainer
282274
const { relatedTarget } = event;
283275
if (!relatedTarget || !containerRef.current?.contains(relatedTarget)) {
284276
setIsFocused(false);
285277
onBlur?.();
286278
}
287279
}}
288280
>
281+
{labelWithOptionalSuffix && (
282+
<label
283+
htmlFor={firstInputId}
284+
className={classNames(styles.clickOverlay, isActive && styles.disabled)}
285+
>
286+
<span>{labelWithOptionalSuffix}</span>
287+
</label>
288+
)}
289289
<NotchedBorder
290290
isActive={isActive}
291291
isFocused={isFocused}
292292
label={labelWithOptionalSuffix ?? ''}
293293
isDanger={!!errorMessage}
294294
/>
295-
{isActive &&
296-
formatConfig?.parts.map((part, index) => (
297-
<Fragment key={part}>
298-
<input
299-
ref={(element) => {
300-
// eslint-disable-next-line @silverhand/fp/no-mutation
301-
inputReferences.current[index] = element ?? undefined;
302-
}}
303-
data-id={String(index)}
304-
placeholder={part.toUpperCase()}
305-
type="text"
306-
inputMode="numeric"
307-
pattern="[0-9]*"
308-
autoComplete="off"
309-
// Fallback solution to `field-sizing` in CSS for non-chromium browsers
310-
size={getDefaultInputSize(formatConfig.maxLengths[index])}
311-
value={dateParts[index] ?? ''}
312-
onPaste={onPasteHandler}
313-
onInput={onInputHandler}
314-
onKeyDown={onKeyDownHandler}
315-
onFocus={() => {
316-
setIsFocused(true);
317-
}}
318-
/>
319-
{index < formatConfig.parts.length - 1 && (
320-
<span className={styles.separator}>{formatConfig.separator}</span>
321-
)}
322-
</Fragment>
323-
))}
295+
{formatConfig?.parts.map((part, index) => (
296+
<Fragment key={part}>
297+
<input
298+
ref={(element) => {
299+
// eslint-disable-next-line @silverhand/fp/no-mutation
300+
inputReferences.current[index] = element ?? undefined;
301+
}}
302+
id={cond(index === 0 && firstInputId)}
303+
data-id={index}
304+
className={classNames(isActive && styles.active)}
305+
placeholder={part.toUpperCase()}
306+
type="text"
307+
inputMode="numeric"
308+
pattern="[0-9]*"
309+
autoComplete="off"
310+
// Fallback solution to `field-sizing` in CSS for non-chromium browsers
311+
size={getDefaultInputSize(formatConfig.maxLengths[index])}
312+
value={dateParts[index] ?? ''}
313+
onPaste={onPasteHandler}
314+
onInput={onInputHandler}
315+
onKeyDown={onKeyDownHandler}
316+
onFocus={() => {
317+
setIsFocused(true);
318+
}}
319+
/>
320+
{index < formatConfig.parts.length - 1 && (
321+
<span className={classNames(isActive && styles.active, styles.separator)}>
322+
{formatConfig.separator}
323+
</span>
324+
)}
325+
</Fragment>
326+
))}
324327
</div>
325328
{description && <div className={styles.description}>{description}</div>}
326329
{errorMessage && <div className={styles.errorMessage}>{errorMessage}</div>}

packages/experience/src/components/InputFields/PrimitiveProfileInputField/index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const isGenderOptionKey = (key: string): key is Gender =>
2020

2121
const PrimitiveProfileInputField = ({
2222
className,
23+
name,
2324
label,
2425
type,
2526
config,
@@ -45,6 +46,7 @@ const PrimitiveProfileInputField = ({
4546
return (
4647
<SelectField
4748
className={className}
49+
name={name}
4850
label={label}
4951
options={options}
5052
value={value}
@@ -60,6 +62,7 @@ const PrimitiveProfileInputField = ({
6062
return (
6163
<CheckboxField
6264
className={className}
65+
name={name}
6366
title={label}
6467
checked={value === 'true'}
6568
value={value}
@@ -73,6 +76,7 @@ const PrimitiveProfileInputField = ({
7376
return (
7477
<DateField
7578
className={className}
79+
name={name}
7680
label={label}
7781
dateFormat={config?.format}
7882
description={description}
@@ -88,6 +92,7 @@ const PrimitiveProfileInputField = ({
8892
return (
8993
<InputField
9094
className={className}
95+
name={name}
9196
label={label}
9297
description={description}
9398
value={value ?? ''}

0 commit comments

Comments
 (0)