@arbor-education/design-system.components 4.9.2 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.js +2 -1
- package/dist/components/datePicker/DatePicker.js.map +1 -1
- package/dist/components/datePicker/DatePicker.test.js +32 -0
- package/dist/components/datePicker/DatePicker.test.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
- package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
- package/dist/components/datePicker/dateInputUtils.js +20 -0
- package/dist/components/datePicker/dateInputUtils.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
- package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
- package/dist/components/datePicker/dateInputUtils.test.js +28 -0
- package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
- package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
- package/dist/components/formField/FormField.d.ts +1 -0
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +2 -2
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts +9 -0
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +28 -16
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +23 -1
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
- package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
- package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
- package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.js +24 -8
- package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
- package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
- package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
- package/dist/components/kvpList/KVPList.d.ts +2 -1
- package/dist/components/kvpList/KVPList.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.js +1 -1
- package/dist/components/kvpList/KVPList.js.map +1 -1
- package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
- package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.stories.js +35 -0
- package/dist/components/kvpList/KVPList.stories.js.map +1 -1
- package/dist/components/kvpList/KVPList.test.js +16 -0
- package/dist/components/kvpList/KVPList.test.js.map +1 -1
- package/dist/components/row/Row.d.ts +1 -0
- package/dist/components/row/Row.d.ts.map +1 -1
- package/dist/components/row/Row.js +3 -2
- package/dist/components/row/Row.js.map +1 -1
- package/dist/components/row/Row.stories.d.ts +13 -0
- package/dist/components/row/Row.stories.d.ts.map +1 -1
- package/dist/components/row/Row.stories.js +53 -0
- package/dist/components/row/Row.stories.js.map +1 -1
- package/dist/components/row/Row.test.js +40 -0
- package/dist/components/row/Row.test.js.map +1 -1
- package/dist/index.css +10 -5
- package/dist/index.css.map +1 -1
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/banner/banner.scss +4 -0
- package/src/components/datePicker/DatePicker.test.tsx +48 -0
- package/src/components/datePicker/DatePicker.tsx +2 -1
- package/src/components/datePicker/dateInputUtils.test.ts +29 -0
- package/src/components/datePicker/dateInputUtils.ts +24 -0
- package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
- package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
- package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
- package/src/components/formField/FormField.stories.tsx +32 -18
- package/src/components/formField/FormField.test.tsx +28 -1
- package/src/components/formField/FormField.tsx +3 -2
- package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
- package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
- package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
- package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
- package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
- package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
- package/src/components/formField/inputs/time/timeInput.scss +0 -6
- package/src/components/kvpList/KVPList.stories.tsx +54 -0
- package/src/components/kvpList/KVPList.test.tsx +52 -0
- package/src/components/kvpList/KVPList.tsx +10 -1
- package/src/components/kvpList/kvpList.scss +4 -0
- package/src/components/row/Row.stories.tsx +63 -0
- package/src/components/row/Row.test.tsx +59 -0
- package/src/components/row/Row.tsx +4 -0
- package/src/components/row/row.scss +5 -2
- package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
- package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useIsControlledOnceSet.test.d.ts","sourceRoot":"","sources":["../../../src/utils/hooks/useIsControlledOnceSet.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { useIsControlledOnceSet } from './useIsControlledOnceSet.js';
|
|
4
|
+
describe('useIsControlledOnceSet', () => {
|
|
5
|
+
test('is uncontrolled while no value has been received', () => {
|
|
6
|
+
const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: undefined } });
|
|
7
|
+
rerender({ value: undefined });
|
|
8
|
+
expect(result.current).toBe(false);
|
|
9
|
+
});
|
|
10
|
+
test('is controlled from the first render with a value', () => {
|
|
11
|
+
const { result } = renderHook(() => useIsControlledOnceSet(new Date(2026, 3, 14)));
|
|
12
|
+
expect(result.current).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
test('becomes controlled when a value arrives after an empty mount', () => {
|
|
15
|
+
const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: undefined } });
|
|
16
|
+
rerender({ value: new Date(2026, 3, 14) });
|
|
17
|
+
expect(result.current).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
test('stays controlled when the value is cleared', () => {
|
|
20
|
+
const { result, rerender } = renderHook(({ value }) => useIsControlledOnceSet(value), { initialProps: { value: new Date(2026, 3, 14) } });
|
|
21
|
+
rerender({ value: undefined });
|
|
22
|
+
expect(result.current).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
//# sourceMappingURL=useIsControlledOnceSet.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useIsControlledOnceSet.test.js","sourceRoot":"","sources":["../../../src/utils/hooks/useIsControlledOnceSet.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AACpD,OAAO,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAC;AAErE,QAAQ,CAAC,wBAAwB,EAAE,GAAG,EAAE;IACtC,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,SAA6B,EAAE,EAAE,CAC3D,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAE/B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC5D,MAAM,EAAE,MAAM,EAAE,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,sBAAsB,CAAC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;QAEnF,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8DAA8D,EAAE,GAAG,EAAE;QACxE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,SAA6B,EAAE,EAAE,CAC3D,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC;QAE3C,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,4CAA4C,EAAE,GAAG,EAAE;QACtD,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,UAAU,CACrC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,sBAAsB,CAAC,KAAK,CAAC,EAC5C,EAAE,YAAY,EAAE,EAAE,KAAK,EAAE,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAqB,EAAE,EAAE,CACvE,CAAC;QAEF,QAAQ,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAE/B,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -223,6 +223,15 @@ describe('DatePicker', () => {
|
|
|
223
223
|
render(<DatePicker defaultValue={new Date(2024, 5, 15)} onChange={onChange} />);
|
|
224
224
|
expect(onChange).not.toHaveBeenCalled();
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test('keeps the user-entered value across re-renders', () => {
|
|
228
|
+
const { rerender } = render(<DatePicker aria-label="Start date" defaultValue={new Date(2024, 5, 15)} />);
|
|
229
|
+
|
|
230
|
+
fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2024-12-25' } });
|
|
231
|
+
rerender(<DatePicker aria-label="Start date" defaultValue={new Date(2024, 5, 15)} />);
|
|
232
|
+
|
|
233
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('2024-12-25');
|
|
234
|
+
});
|
|
226
235
|
});
|
|
227
236
|
|
|
228
237
|
describe('onChange call-count correctness', () => {
|
|
@@ -285,5 +294,44 @@ describe('DatePicker', () => {
|
|
|
285
294
|
const lastDate: Date = onChange.mock.lastCall![0];
|
|
286
295
|
expect(lastDate.getDate()).toBe(20);
|
|
287
296
|
});
|
|
297
|
+
|
|
298
|
+
test('clearing the value empties the input and calendar selection without firing onChange', async () => {
|
|
299
|
+
const onChange = vi.fn();
|
|
300
|
+
const { rerender } = render(
|
|
301
|
+
<DatePicker aria-label="Start date" value={new Date(2024, 5, 15)} onChange={onChange} />,
|
|
302
|
+
);
|
|
303
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
|
304
|
+
const calendar = screen.getByRole('application');
|
|
305
|
+
expect(within(calendar).getByRole('gridcell', { selected: true })).toBeInTheDocument();
|
|
306
|
+
onChange.mockClear();
|
|
307
|
+
|
|
308
|
+
rerender(<DatePicker aria-label="Start date" value={undefined} onChange={onChange} />);
|
|
309
|
+
|
|
310
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('');
|
|
311
|
+
expect(within(calendar).queryByRole('gridcell', { selected: true })).not.toBeInTheDocument();
|
|
312
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('a value that starts empty displays a value supplied later', () => {
|
|
316
|
+
const { rerender } = render(<DatePicker aria-label="Start date" value={undefined} />);
|
|
317
|
+
|
|
318
|
+
rerender(<DatePicker aria-label="Start date" value={new Date(2024, 11, 25)} />);
|
|
319
|
+
|
|
320
|
+
expect(screen.getByLabelText('Start date')).toHaveValue(formatNativeDateInputValue(new Date(2024, 11, 25)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a cleared value still accepts a new date from the user', () => {
|
|
324
|
+
const onChange = vi.fn();
|
|
325
|
+
const { rerender } = render(
|
|
326
|
+
<DatePicker aria-label="Start date" value={new Date(2024, 5, 15)} onChange={onChange} />,
|
|
327
|
+
);
|
|
328
|
+
rerender(<DatePicker aria-label="Start date" value={undefined} onChange={onChange} />);
|
|
329
|
+
|
|
330
|
+
fireEvent.change(screen.getByLabelText('Start date'), { target: { value: '2024-12-25' } });
|
|
331
|
+
|
|
332
|
+
expect(screen.getByLabelText('Start date')).toHaveValue('2024-12-25');
|
|
333
|
+
const lastDate: Date = onChange.mock.lastCall![0];
|
|
334
|
+
expect([lastDate.getFullYear(), lastDate.getMonth(), lastDate.getDate()]).toEqual([2024, 11, 25]);
|
|
335
|
+
});
|
|
288
336
|
});
|
|
289
337
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ChangeEvent,
|
|
12
12
|
type PointerEvent,
|
|
13
13
|
} from 'react';
|
|
14
|
+
import { useIsControlledOnceSet } from 'Utils/hooks/useIsControlledOnceSet';
|
|
14
15
|
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
15
16
|
import {
|
|
16
17
|
formatNativeDateInputValue,
|
|
@@ -59,7 +60,7 @@ export const DatePicker = (props: DatePickerProps) => {
|
|
|
59
60
|
} = props;
|
|
60
61
|
|
|
61
62
|
const initialValue = value ?? defaultValue;
|
|
62
|
-
const isControlled = value
|
|
63
|
+
const isControlled = useIsControlledOnceSet(value);
|
|
63
64
|
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
64
65
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>(
|
|
65
66
|
initialValue,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { parseCompleteTime } from './dateInputUtils.js';
|
|
3
|
+
|
|
4
|
+
describe('parseCompleteTime', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
['14:30', 'minute', '14:30'],
|
|
7
|
+
['9:05', 'minute', '09:05'],
|
|
8
|
+
['00:00', 'minute', '00:00'],
|
|
9
|
+
['23:59', 'minute', '23:59'],
|
|
10
|
+
['14:30', 'second', '14:30:00'],
|
|
11
|
+
['14:30:15', 'second', '14:30:15'],
|
|
12
|
+
] as const)('normalises %s at %s granularity to %s', (value, granularity, expected) => {
|
|
13
|
+
expect(parseCompleteTime(value, granularity)).toBe(expected);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test.each([
|
|
17
|
+
['', 'minute'],
|
|
18
|
+
['905', 'minute'],
|
|
19
|
+
['9', 'minute'],
|
|
20
|
+
['14:', 'minute'],
|
|
21
|
+
['14:3', 'minute'],
|
|
22
|
+
['25:00', 'minute'],
|
|
23
|
+
['14:75', 'minute'],
|
|
24
|
+
['14:30:15', 'minute'],
|
|
25
|
+
['14:30:60', 'second'],
|
|
26
|
+
] as const)('rejects partial, out-of-range or wrong-precision %s at %s granularity', (value, granularity) => {
|
|
27
|
+
expect(parseCompleteTime(value, granularity)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -105,6 +105,30 @@ export const formatTimeInputValue = (value: Date, granularity: TimeGranularity):
|
|
|
105
105
|
format(value, getTimeFormatString(granularity)) as TimeValue
|
|
106
106
|
);
|
|
107
107
|
|
|
108
|
+
const COMPLETE_TIME_PATTERN = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
|
109
|
+
const MAX_HOURS = 23;
|
|
110
|
+
const MAX_MINUTES_OR_SECONDS = 59;
|
|
111
|
+
|
|
112
|
+
export const parseCompleteTime = (value: string, granularity: TimeGranularity): string | null => {
|
|
113
|
+
const match = COMPLETE_TIME_PATTERN.exec(value);
|
|
114
|
+
if (!match) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const [, rawHours = '', minutes = '', rawSeconds] = match;
|
|
119
|
+
const hours = Number(rawHours);
|
|
120
|
+
const seconds = rawSeconds ?? '00';
|
|
121
|
+
if (granularity === 'minute' && rawSeconds !== undefined) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (hours > MAX_HOURS || Number(minutes) > MAX_MINUTES_OR_SECONDS || Number(seconds) > MAX_MINUTES_OR_SECONDS) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const paddedTime = `${String(hours).padStart(2, '0')}:${minutes}`;
|
|
129
|
+
return granularity === 'second' ? `${paddedTime}:${seconds}` : paddedTime;
|
|
130
|
+
};
|
|
131
|
+
|
|
108
132
|
export const mergeDateAndTime = (date: Date, timeValue: string, granularity: TimeGranularity): Date => {
|
|
109
133
|
const [rawHours = '0', rawMinutes = '0', rawSeconds = '0'] = timeValue.split(':');
|
|
110
134
|
const hours = Number.parseInt(rawHours, 10) || 0;
|
|
@@ -77,6 +77,11 @@ const DEVELOPER_NOTES = [
|
|
|
77
77
|
'**`timeOptions` switches the embedded time input to combobox mode.** Instead of a free-form',
|
|
78
78
|
'time input, the user picks from a predefined list. Use for fixed appointment slots.',
|
|
79
79
|
'',
|
|
80
|
+
'**Typed times commit on blur, Enter or closing the popup.** In free-form mode the embedded time',
|
|
81
|
+
'input holds what the user types, and the date changes only once a complete, in-range time is',
|
|
82
|
+
'committed. Partial or out-of-range text never changes the date. In combobox mode, choosing an',
|
|
83
|
+
'option commits immediately.',
|
|
84
|
+
'',
|
|
80
85
|
'**`hasError` is visual-only when used standalone.** It applies the red border but does NOT',
|
|
81
86
|
'set `aria-invalid`. Always pair both when outside `<FormField>`:',
|
|
82
87
|
'',
|
|
@@ -188,6 +188,142 @@ describe('DateTimePicker', () => {
|
|
|
188
188
|
expect(lastDate.getMinutes()).toBe(30);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
+
test('partial text typed into the popup time field does not change the date', async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
const onChange = vi.fn();
|
|
194
|
+
|
|
195
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
|
|
196
|
+
onChange.mockClear();
|
|
197
|
+
|
|
198
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
199
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
200
|
+
await user.clear(timeInput);
|
|
201
|
+
await user.type(timeInput, '14');
|
|
202
|
+
|
|
203
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('out-of-range text in the popup time field does not change the date on Enter', async () => {
|
|
207
|
+
const user = userEvent.setup();
|
|
208
|
+
const onChange = vi.fn();
|
|
209
|
+
|
|
210
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} onChange={onChange} />);
|
|
211
|
+
onChange.mockClear();
|
|
212
|
+
|
|
213
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
214
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
215
|
+
await user.clear(timeInput);
|
|
216
|
+
await user.type(timeInput, '25:00{Enter}');
|
|
217
|
+
|
|
218
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('typing in the popup time field does not emit until the time is committed', async () => {
|
|
222
|
+
const user = userEvent.setup();
|
|
223
|
+
const onChange = vi.fn();
|
|
224
|
+
|
|
225
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
226
|
+
onChange.mockClear();
|
|
227
|
+
|
|
228
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
229
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
230
|
+
await user.clear(timeInput);
|
|
231
|
+
await user.type(timeInput, '14:30');
|
|
232
|
+
|
|
233
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
234
|
+
expect(timeInput).toHaveValue('14:30');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('Enter in the popup time field commits the typed time once', async () => {
|
|
238
|
+
const user = userEvent.setup();
|
|
239
|
+
const onChange = vi.fn();
|
|
240
|
+
|
|
241
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
242
|
+
onChange.mockClear();
|
|
243
|
+
|
|
244
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
245
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
246
|
+
await user.clear(timeInput);
|
|
247
|
+
await user.type(timeInput, '14:30{Enter}');
|
|
248
|
+
|
|
249
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
250
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
251
|
+
expect(emitted.getDate()).toBe(1);
|
|
252
|
+
expect(emitted.getHours()).toBe(14);
|
|
253
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('leaving the popup time field commits the typed time', async () => {
|
|
257
|
+
const user = userEvent.setup();
|
|
258
|
+
const onChange = vi.fn();
|
|
259
|
+
|
|
260
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
261
|
+
onChange.mockClear();
|
|
262
|
+
|
|
263
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
264
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
265
|
+
await user.clear(timeInput);
|
|
266
|
+
await user.type(timeInput, '14:30');
|
|
267
|
+
await user.tab();
|
|
268
|
+
|
|
269
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
270
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
271
|
+
expect(emitted.getHours()).toBe(14);
|
|
272
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('closing the popup with Escape commits the typed time', async () => {
|
|
276
|
+
const user = userEvent.setup();
|
|
277
|
+
const onChange = vi.fn();
|
|
278
|
+
|
|
279
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 0)} onChange={onChange} />);
|
|
280
|
+
onChange.mockClear();
|
|
281
|
+
|
|
282
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
283
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
284
|
+
await user.clear(timeInput);
|
|
285
|
+
await user.type(timeInput, '14:30');
|
|
286
|
+
await user.keyboard('{Escape}');
|
|
287
|
+
|
|
288
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
289
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
290
|
+
expect(emitted.getHours()).toBe(14);
|
|
291
|
+
expect(emitted.getMinutes()).toBe(30);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a partial time that fails to commit is replaced with the selected time on blur', async () => {
|
|
295
|
+
const user = userEvent.setup();
|
|
296
|
+
|
|
297
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 14, 30)} />);
|
|
298
|
+
|
|
299
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
300
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
301
|
+
await user.clear(timeInput);
|
|
302
|
+
await user.type(timeInput, '14');
|
|
303
|
+
await user.tab();
|
|
304
|
+
|
|
305
|
+
expect(timeInput).toHaveValue('14:30');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('picking a day keeps the selected time when the popup time text is partial', async () => {
|
|
309
|
+
const user = userEvent.setup();
|
|
310
|
+
const onChange = vi.fn();
|
|
311
|
+
|
|
312
|
+
render(<DateTimePicker value={new Date(2024, 5, 1, 9, 15)} onChange={onChange} />);
|
|
313
|
+
onChange.mockClear();
|
|
314
|
+
|
|
315
|
+
await user.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
316
|
+
const timeInput = screen.getByLabelText('Select time', { selector: 'input' });
|
|
317
|
+
await user.clear(timeInput);
|
|
318
|
+
await user.type(timeInput, '14');
|
|
319
|
+
await user.click(within(screen.getByRole('application')).getByRole('button', { name: /June 20/ }));
|
|
320
|
+
|
|
321
|
+
const emitted: Date = onChange.mock.lastCall![0];
|
|
322
|
+
expect(emitted.getDate()).toBe(20);
|
|
323
|
+
expect(emitted.getHours()).toBe(9);
|
|
324
|
+
expect(emitted.getMinutes()).toBe(15);
|
|
325
|
+
});
|
|
326
|
+
|
|
191
327
|
test('opening the picker after typing a valid combined value syncs month, year, and time controls', async () => {
|
|
192
328
|
const user = userEvent.setup();
|
|
193
329
|
const { container } = render(<DateTimePicker />);
|
|
@@ -245,6 +381,17 @@ describe('DateTimePicker', () => {
|
|
|
245
381
|
render(<DateTimePicker defaultValue={new Date(2024, 5, 15, 14, 30)} onChange={onChange} />);
|
|
246
382
|
expect(onChange).not.toHaveBeenCalled();
|
|
247
383
|
});
|
|
384
|
+
|
|
385
|
+
test('keeps the user-entered value across re-renders', () => {
|
|
386
|
+
const { rerender } = render(
|
|
387
|
+
<DateTimePicker aria-label="Start" defaultValue={new Date(2024, 5, 15, 14, 30)} />,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2024-12-25T09:45' } });
|
|
391
|
+
rerender(<DateTimePicker aria-label="Start" defaultValue={new Date(2024, 5, 15, 14, 30)} />);
|
|
392
|
+
|
|
393
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2024-12-25T09:45');
|
|
394
|
+
});
|
|
248
395
|
});
|
|
249
396
|
|
|
250
397
|
describe('onChange call-count correctness', () => {
|
|
@@ -289,6 +436,45 @@ describe('DateTimePicker', () => {
|
|
|
289
436
|
expect(onChange).not.toHaveBeenCalled();
|
|
290
437
|
});
|
|
291
438
|
|
|
439
|
+
test('clearing a controlled value empties the date, time and calendar selection without firing onChange', () => {
|
|
440
|
+
const onChange = vi.fn();
|
|
441
|
+
const { rerender } = render(
|
|
442
|
+
<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} onChange={onChange} />,
|
|
443
|
+
);
|
|
444
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open date and time picker' }));
|
|
445
|
+
const calendar = screen.getByRole('application');
|
|
446
|
+
expect(within(calendar).getByRole('gridcell', { selected: true })).toBeInTheDocument();
|
|
447
|
+
onChange.mockClear();
|
|
448
|
+
|
|
449
|
+
rerender(<DateTimePicker aria-label="Start" value={undefined} onChange={onChange} />);
|
|
450
|
+
|
|
451
|
+
expect(screen.getByLabelText('Start')).toHaveValue('');
|
|
452
|
+
expect(screen.getByLabelText('Select time', { selector: 'input' })).toHaveValue('');
|
|
453
|
+
expect(within(calendar).queryByRole('gridcell', { selected: true })).not.toBeInTheDocument();
|
|
454
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('a cleared controlled picker still accepts a new date from the user', () => {
|
|
458
|
+
const onChange = vi.fn();
|
|
459
|
+
const { rerender } = render(
|
|
460
|
+
<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} onChange={onChange} />,
|
|
461
|
+
);
|
|
462
|
+
rerender(<DateTimePicker aria-label="Start" value={undefined} onChange={onChange} />);
|
|
463
|
+
|
|
464
|
+
fireEvent.change(screen.getByLabelText('Start'), { target: { value: '2026-05-01T08:15' } });
|
|
465
|
+
|
|
466
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2026-05-01T08:15');
|
|
467
|
+
expect(onChange).toHaveBeenLastCalledWith(new Date(2026, 4, 1, 8, 15));
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('a controlled value that starts empty displays a value supplied later', () => {
|
|
471
|
+
const { rerender } = render(<DateTimePicker aria-label="Start" value={undefined} />);
|
|
472
|
+
|
|
473
|
+
rerender(<DateTimePicker aria-label="Start" value={new Date(2026, 3, 14, 14, 27)} />);
|
|
474
|
+
|
|
475
|
+
expect(screen.getByLabelText('Start')).toHaveValue('2026-04-14T14:27');
|
|
476
|
+
});
|
|
477
|
+
|
|
292
478
|
test('Today preserves the current time and closes the picker', async () => {
|
|
293
479
|
const user = userEvent.setup();
|
|
294
480
|
const onChange = vi.fn();
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
getDateTimePlaceholder,
|
|
10
10
|
getNativeDateTimePlaceholder,
|
|
11
11
|
mergeDateAndTime,
|
|
12
|
+
parseCompleteTime,
|
|
12
13
|
parseDateTimeInputValue,
|
|
13
14
|
parseNativeDateTimeInputValue,
|
|
14
15
|
type DateDisplayFormat,
|
|
@@ -30,8 +31,10 @@ import {
|
|
|
30
31
|
useRef,
|
|
31
32
|
useState,
|
|
32
33
|
type ChangeEvent,
|
|
34
|
+
type KeyboardEvent,
|
|
33
35
|
type PointerEvent,
|
|
34
36
|
} from 'react';
|
|
37
|
+
import { useIsControlledOnceSet } from 'Utils/hooks/useIsControlledOnceSet';
|
|
35
38
|
import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
|
|
36
39
|
|
|
37
40
|
export type DateTimePickerProps = {
|
|
@@ -109,7 +112,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
109
112
|
));
|
|
110
113
|
|
|
111
114
|
const initialValue = value ?? defaultValue;
|
|
112
|
-
const isControlled = value
|
|
115
|
+
const isControlled = useIsControlledOnceSet(value);
|
|
113
116
|
const [month, setMonth] = useState(initialValue ?? new Date());
|
|
114
117
|
const [selectedDateTime, setSelectedDateTime] = useState<Date | undefined>(
|
|
115
118
|
initialValue,
|
|
@@ -189,16 +192,23 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
189
192
|
}
|
|
190
193
|
};
|
|
191
194
|
|
|
192
|
-
const
|
|
193
|
-
setTimeInputValue(nextTimeValue);
|
|
195
|
+
const selectedTimeText = selectedDateTime ? formatTimeInputValue(selectedDateTime, granularity) : '';
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
const commitTimeInput = (nextTimeValue: string) => {
|
|
198
|
+
const completeTime = parseCompleteTime(nextTimeValue, granularity);
|
|
199
|
+
if (completeTime === null) {
|
|
200
|
+
if (selectedDateTime) {
|
|
201
|
+
setTimeInputValue(selectedTimeText);
|
|
202
|
+
}
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (!selectedDateTime || completeTime === selectedTimeText) {
|
|
196
206
|
return;
|
|
197
207
|
}
|
|
198
208
|
|
|
199
209
|
const nextDateTime = mergeDateAndTime(
|
|
200
210
|
selectedDateTime,
|
|
201
|
-
|
|
211
|
+
completeTime,
|
|
202
212
|
granularity,
|
|
203
213
|
);
|
|
204
214
|
setSelectedDateTime(nextDateTime);
|
|
@@ -206,6 +216,19 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
206
216
|
onChange?.(nextDateTime);
|
|
207
217
|
};
|
|
208
218
|
|
|
219
|
+
const handleTimeChange = (nextTimeValue: string) => {
|
|
220
|
+
setTimeInputValue(nextTimeValue);
|
|
221
|
+
if (timeOptions) {
|
|
222
|
+
commitTimeInput(nextTimeValue);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleTimeInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
227
|
+
if (event.key === 'Enter') {
|
|
228
|
+
commitTimeInput(timeInputValue);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
209
232
|
const handleDayPickerSelect = (nextDate?: Date) => {
|
|
210
233
|
if (!nextDate) {
|
|
211
234
|
setSelectedDateTime(undefined);
|
|
@@ -217,7 +240,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
217
240
|
|
|
218
241
|
const nextDateTime = mergeDateAndTime(
|
|
219
242
|
nextDate,
|
|
220
|
-
timeInputValue,
|
|
243
|
+
parseCompleteTime(timeInputValue, granularity) ?? selectedTimeText,
|
|
221
244
|
granularity,
|
|
222
245
|
);
|
|
223
246
|
setSelectedDateTime(nextDateTime);
|
|
@@ -230,7 +253,7 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
230
253
|
};
|
|
231
254
|
|
|
232
255
|
const handleTodayClick = () => {
|
|
233
|
-
const nextDate = mergeDateAndTime(new Date(), timeInputValue, granularity);
|
|
256
|
+
const nextDate = mergeDateAndTime(new Date(), parseCompleteTime(timeInputValue, granularity) ?? selectedTimeText, granularity);
|
|
234
257
|
setSelectedDateTime(nextDate);
|
|
235
258
|
setMonth(nextDate);
|
|
236
259
|
setInputValue(formatInputValue(nextDate));
|
|
@@ -276,7 +299,12 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
276
299
|
</div>
|
|
277
300
|
<Popover.Root
|
|
278
301
|
open={isPickerOpen}
|
|
279
|
-
onOpenChange={open =>
|
|
302
|
+
onOpenChange={(open) => {
|
|
303
|
+
if (!open) {
|
|
304
|
+
commitTimeInput(timeInputValue);
|
|
305
|
+
}
|
|
306
|
+
setIsPickerOpen(open);
|
|
307
|
+
}}
|
|
280
308
|
>
|
|
281
309
|
<Popover.Trigger asChild>
|
|
282
310
|
<Button
|
|
@@ -303,6 +331,8 @@ export const DateTimePicker = (props: DateTimePickerProps) => {
|
|
|
303
331
|
aria-label="Select time"
|
|
304
332
|
value={timeInputValue as TimeValue | ''}
|
|
305
333
|
onValueChange={handleTimeChange}
|
|
334
|
+
onBlur={() => commitTimeInput(timeInputValue)}
|
|
335
|
+
onKeyDown={handleTimeInputKeyDown}
|
|
306
336
|
granularity={granularity}
|
|
307
337
|
options={timeOptions}
|
|
308
338
|
searchType={searchType}
|
|
@@ -94,9 +94,10 @@ const DEVELOPER_NOTES = [
|
|
|
94
94
|
'**7. Always provide `label` when using `helperLinkText`.** The helper link `aria-label` is',
|
|
95
95
|
'constructed as `"${label} helper link"`. Omit `label` and the aria-label becomes `"undefined helper link"`.',
|
|
96
96
|
'',
|
|
97
|
-
'**8. Optional fields:
|
|
98
|
-
'
|
|
99
|
-
'`
|
|
97
|
+
'**8. Optional fields: pass `optional`.** FormField appends `"(optional)"` to the label for you,',
|
|
98
|
+
'e.g. `label="Middle name" optional` renders "Middle name (optional)". There is no asterisk pattern.',
|
|
99
|
+
'Omit `optional` and the label is rendered untouched. `optional` only changes the label;',
|
|
100
|
+
'pass native `required` via `inputProps` for form validation and assistive technology semantics.',
|
|
100
101
|
'',
|
|
101
102
|
'**9. `NumberInput` renders `type="text"` internally.** Do not pass `type` in `inputProps` when',
|
|
102
103
|
'`inputType="number"` — it is ignored and may produce confusing behaviour. The component uses',
|
|
@@ -226,14 +227,23 @@ const meta = {
|
|
|
226
227
|
control: 'text',
|
|
227
228
|
description: [
|
|
228
229
|
'Visible label rendered above the input via the `Label` component (`ds-label`).',
|
|
229
|
-
'Short, sentence case.
|
|
230
|
-
'no `required` prop and no asterisk convention.',
|
|
230
|
+
'Short, sentence case. Do not add an `"(optional)"` suffix yourself — pass `optional`.',
|
|
231
231
|
'**Also required when using `helperLinkText`** — the link\'s `aria-label` uses this value verbatim.',
|
|
232
232
|
].join(' '),
|
|
233
233
|
table: {
|
|
234
234
|
type: { summary: 'string' },
|
|
235
235
|
},
|
|
236
236
|
},
|
|
237
|
+
optional: {
|
|
238
|
+
control: 'boolean',
|
|
239
|
+
description: [
|
|
240
|
+
'Controls the label treatment. `true` appends `"(optional)"` to the label; `false` or omitted leaves',
|
|
241
|
+
'the label untouched. Does not affect the native `required` attribute — use `inputProps.required` for that.',
|
|
242
|
+
].join(' '),
|
|
243
|
+
table: {
|
|
244
|
+
type: { summary: 'boolean' },
|
|
245
|
+
},
|
|
246
|
+
},
|
|
237
247
|
inputType: {
|
|
238
248
|
control: 'select',
|
|
239
249
|
options: ['text', 'textarea', 'number', 'time', 'colourPicker', 'selectDropdown', 'datePicker', 'combobox', 'checkbox', 'checkboxGroup', 'toggle', 'radio', 'singleFile', 'custom'],
|
|
@@ -815,13 +825,12 @@ export default TextAreaExample;
|
|
|
815
825
|
),
|
|
816
826
|
},
|
|
817
827
|
[
|
|
818
|
-
'`inputType="textarea"` renders a `TextArea` — a multi-line input that
|
|
819
|
-
'
|
|
820
|
-
'`rows` prop in `inputProps`.',
|
|
828
|
+
'`inputType="textarea"` renders a `TextArea` — a multi-line input that sizes itself to its content',
|
|
829
|
+
'(`autoSize={true}` is the default). The `rows` prop in `inputProps` sets the minimum starting height.',
|
|
821
830
|
'',
|
|
822
831
|
'**Use for:** pastoral notes, medical conditions, free-text feedback, long descriptions.',
|
|
823
832
|
'',
|
|
824
|
-
'**Auto-sizing:** the textarea
|
|
833
|
+
'**Auto-sizing:** the textarea fits its content on mount, when the controlled `value` changes, and as the user types — it does not scroll.',
|
|
825
834
|
'To opt out, pass `autoSize: false` in `inputProps`.',
|
|
826
835
|
'',
|
|
827
836
|
'**All shared wiring still applies:** `errorText`, `fieldDescription`, and `id` work identically.',
|
|
@@ -1291,17 +1300,19 @@ function OptionalAndRequiredFieldsExample() {
|
|
|
1291
1300
|
/>
|
|
1292
1301
|
<FormField
|
|
1293
1302
|
id="student-middle-name"
|
|
1294
|
-
label="Middle name
|
|
1303
|
+
label="Middle name"
|
|
1304
|
+
optional
|
|
1295
1305
|
inputProps={{ placeholder: 'e.g. Marie' }}
|
|
1296
1306
|
/>
|
|
1297
1307
|
<FormField
|
|
1298
1308
|
id="student-surname"
|
|
1299
1309
|
label="Surname"
|
|
1300
|
-
inputProps={{ placeholder: 'e.g. Nylund' }}
|
|
1310
|
+
inputProps={{ placeholder: 'e.g. Nylund', required: true }}
|
|
1301
1311
|
/>
|
|
1302
1312
|
<FormField
|
|
1303
1313
|
id="student-preferred-name"
|
|
1304
|
-
label="Preferred name
|
|
1314
|
+
label="Preferred name"
|
|
1315
|
+
optional
|
|
1305
1316
|
fieldDescription="Used in class registers and parent communications"
|
|
1306
1317
|
inputProps={{ placeholder: 'e.g. Rosie' }}
|
|
1307
1318
|
/>
|
|
@@ -1328,17 +1339,19 @@ export default OptionalAndRequiredFieldsExample;
|
|
|
1328
1339
|
/>
|
|
1329
1340
|
<FormField
|
|
1330
1341
|
id="student-middle-name"
|
|
1331
|
-
label="Middle name
|
|
1342
|
+
label="Middle name"
|
|
1343
|
+
optional
|
|
1332
1344
|
inputProps={{ placeholder: 'e.g. Marie' }}
|
|
1333
1345
|
/>
|
|
1334
1346
|
<FormField
|
|
1335
1347
|
id="student-surname"
|
|
1336
1348
|
label="Surname"
|
|
1337
|
-
inputProps={{ placeholder: 'e.g. Nylund' }}
|
|
1349
|
+
inputProps={{ placeholder: 'e.g. Nylund', required: true }}
|
|
1338
1350
|
/>
|
|
1339
1351
|
<FormField
|
|
1340
1352
|
id="student-preferred-name"
|
|
1341
|
-
label="Preferred name
|
|
1353
|
+
label="Preferred name"
|
|
1354
|
+
optional
|
|
1342
1355
|
fieldDescription="Used in class registers and parent communications"
|
|
1343
1356
|
inputProps={{ placeholder: 'e.g. Rosie' }}
|
|
1344
1357
|
/>
|
|
@@ -1352,10 +1365,11 @@ export default OptionalAndRequiredFieldsExample;
|
|
|
1352
1365
|
),
|
|
1353
1366
|
},
|
|
1354
1367
|
[
|
|
1355
|
-
'Arbor uses the **label suffix convention** for optional fields — no asterisk
|
|
1356
|
-
'
|
|
1368
|
+
'Arbor uses the **label suffix convention** for optional fields — no asterisk.',
|
|
1369
|
+
'Pass `optional` and FormField appends `"(optional)"` to the label: `label="Middle name" optional`.',
|
|
1357
1370
|
'',
|
|
1358
|
-
'Fields without
|
|
1371
|
+
'Fields rendered without `optional` carry no suffix and are implicitly required. Pass native',
|
|
1372
|
+
'`required` via `inputProps` as well so form validation and assistive technology agree. This is a deliberate Confluence design',
|
|
1359
1373
|
'decision: marking optional fields (the minority) is less visually noisy than marking required',
|
|
1360
1374
|
'fields (the majority) with an asterisk that users learn to ignore anyway.',
|
|
1361
1375
|
'',
|