@arbor-education/design-system.components 4.9.1 → 5.0.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.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +3 -4
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +1 -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/components/table/cellEditors/NumberCellEditor.d.ts +2 -2
- package/dist/components/table/cellEditors/NumberCellEditor.d.ts.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.js +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.js.map +1 -1
- package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts +2 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts.map +1 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.js +57 -0
- package/dist/components/table/cellEditors/NumberCellEditor.test.js.map +1 -0
- 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 +3 -4
- package/src/components/formField/FormField.test.tsx +1 -1
- 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/components/table/cellEditors/NumberCellEditor.test.tsx +74 -0
- package/src/components/table/cellEditors/NumberCellEditor.tsx +4 -4
- package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
- package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import { expect, describe, test, vi } from 'vitest';
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, beforeEach, expect, describe, test, vi } from 'vitest';
|
|
2
|
+
import { createRef } from 'react';
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
4
|
import { TextArea } from './TextArea.js';
|
|
4
5
|
import '@testing-library/jest-dom/vitest';
|
|
5
6
|
|
|
7
|
+
const SHORT_TEXT = 'Short note';
|
|
8
|
+
const LONG_TEXT = 'Pupil receives 1:1 literacy support three times per week. Extra time applies in all timed assessments.';
|
|
9
|
+
const SHORT_SCROLL_HEIGHT = 40;
|
|
10
|
+
const LONG_SCROLL_HEIGHT = 120;
|
|
11
|
+
const VERTICAL_PADDING = 4;
|
|
12
|
+
|
|
6
13
|
describe('TextArea component', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLTextAreaElement) {
|
|
16
|
+
return this.value.length > SHORT_TEXT.length ? LONG_SCROLL_HEIGHT : SHORT_SCROLL_HEIGHT;
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
7
24
|
test('TextArea says hello', () => {
|
|
8
25
|
render(<TextArea placeholder="Hello I'm a TextArea!" />);
|
|
9
26
|
expect(screen.getByPlaceholderText("Hello I'm a TextArea!")).toBeInTheDocument();
|
|
@@ -16,4 +33,79 @@ describe('TextArea component', () => {
|
|
|
16
33
|
expect(onChange).toHaveBeenCalled();
|
|
17
34
|
expect((screen.getByPlaceholderText('Enter text') as HTMLInputElement).value).toBe('Hello');
|
|
18
35
|
});
|
|
36
|
+
|
|
37
|
+
test('fits its height to a long defaultValue on mount', () => {
|
|
38
|
+
render(<TextArea aria-label="Notes" rows={2} defaultValue={LONG_TEXT} />);
|
|
39
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('fits its height to a long controlled value on mount', () => {
|
|
43
|
+
render(<TextArea aria-label="Notes" rows={2} value={LONG_TEXT} onChange={vi.fn()} />);
|
|
44
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('refits its height when the controlled value changes externally', () => {
|
|
48
|
+
const { rerender } = render(<TextArea aria-label="Notes" value={SHORT_TEXT} onChange={vi.fn()} />);
|
|
49
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: `${SHORT_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
50
|
+
|
|
51
|
+
rerender(<TextArea aria-label="Notes" value={LONG_TEXT} onChange={vi.fn()} />);
|
|
52
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('keeps its rows height on mount when autoSize is false', () => {
|
|
56
|
+
render(<TextArea aria-label="Notes" rows={2} autoSize={false} defaultValue={LONG_TEXT} />);
|
|
57
|
+
expect(screen.getByRole('textbox', { name: 'Notes' }).style.height).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('resizes while typing and still calls onChange', () => {
|
|
61
|
+
const onChange = vi.fn();
|
|
62
|
+
render(<TextArea aria-label="Notes" onChange={onChange} />);
|
|
63
|
+
const textArea = screen.getByRole('textbox', { name: 'Notes' });
|
|
64
|
+
|
|
65
|
+
fireEvent.change(textArea, { target: { value: LONG_TEXT } });
|
|
66
|
+
|
|
67
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
68
|
+
expect(textArea).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('forwards a ref to the underlying textarea element', () => {
|
|
72
|
+
const textAreaRef = createRef<HTMLTextAreaElement>();
|
|
73
|
+
render(<TextArea aria-label="Notes" ref={textAreaRef} />);
|
|
74
|
+
expect(textAreaRef.current).toBe(screen.getByRole('textbox', { name: 'Notes' }));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('keeps its rows height when mounted while not rendered', () => {
|
|
78
|
+
vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockReturnValue(0);
|
|
79
|
+
render(<TextArea aria-label="Notes" rows={2} defaultValue={LONG_TEXT} />);
|
|
80
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: 'auto' });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('refits its height once web fonts have loaded', async () => {
|
|
84
|
+
let resolveFontsReady: () => void = () => {};
|
|
85
|
+
const fontsReady = new Promise<void>((resolve) => {
|
|
86
|
+
resolveFontsReady = resolve;
|
|
87
|
+
});
|
|
88
|
+
Object.defineProperty(document, 'fonts', { value: { ready: fontsReady }, configurable: true });
|
|
89
|
+
const scrollHeightSpy = vi.spyOn(HTMLTextAreaElement.prototype, 'scrollHeight', 'get').mockReturnValue(LONG_SCROLL_HEIGHT);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
render(<TextArea aria-label="Notes" defaultValue={LONG_TEXT} />);
|
|
93
|
+
const textArea = screen.getByRole('textbox', { name: 'Notes' });
|
|
94
|
+
expect(textArea).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
95
|
+
|
|
96
|
+
scrollHeightSpy.mockReturnValue(SHORT_SCROLL_HEIGHT);
|
|
97
|
+
resolveFontsReady();
|
|
98
|
+
|
|
99
|
+
await waitFor(() => expect(textArea).toHaveStyle({ height: `${SHORT_SCROLL_HEIGHT + VERTICAL_PADDING}px` }));
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
Reflect.deleteProperty(document, 'fonts');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('still autosizes when a consumer passes a ref', () => {
|
|
107
|
+
const textAreaRef = createRef<HTMLTextAreaElement>();
|
|
108
|
+
render(<TextArea aria-label="Notes" ref={textAreaRef} defaultValue={LONG_TEXT} />);
|
|
109
|
+
expect(screen.getByRole('textbox', { name: 'Notes' })).toHaveStyle({ height: `${LONG_SCROLL_HEIGHT + VERTICAL_PADDING}px` });
|
|
110
|
+
});
|
|
19
111
|
});
|
|
@@ -1,24 +1,56 @@
|
|
|
1
|
-
import { useRef } from 'react';
|
|
1
|
+
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
import classNames from 'classnames';
|
|
3
3
|
import type { ChangeEvent, TextareaHTMLAttributes } from 'react';
|
|
4
|
+
import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
|
|
4
5
|
|
|
5
6
|
export type TextAreaProps = {
|
|
6
7
|
hasError?: boolean;
|
|
7
8
|
autoSize?: boolean;
|
|
8
9
|
} & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'size'>;
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
const VERTICAL_PADDING = 4;
|
|
12
|
+
|
|
13
|
+
const autosizeTextArea = (textArea: HTMLTextAreaElement | null) => {
|
|
14
|
+
if (!textArea) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
textArea.style.height = 'auto';
|
|
18
|
+
const contentHeight = textArea.scrollHeight;
|
|
19
|
+
// A textarea that is not rendered (e.g. inside a hidden tab) measures 0; keep its rows height instead of collapsing it
|
|
20
|
+
if (contentHeight > 0) {
|
|
21
|
+
textArea.style.height = `${contentHeight + VERTICAL_PADDING}px`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>((props, ref) => {
|
|
11
26
|
const {
|
|
12
27
|
hasError,
|
|
13
28
|
className = '',
|
|
14
29
|
disabled = false,
|
|
15
30
|
autoSize = true,
|
|
16
31
|
onChange,
|
|
32
|
+
value,
|
|
17
33
|
...rest
|
|
18
34
|
} = props;
|
|
19
35
|
|
|
20
36
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
21
37
|
|
|
38
|
+
useImperativeHandle(ref, () => inputRef.current as HTMLTextAreaElement, []);
|
|
39
|
+
|
|
40
|
+
useLayoutEffect(() => {
|
|
41
|
+
if (autoSize) {
|
|
42
|
+
autosizeTextArea(inputRef.current);
|
|
43
|
+
}
|
|
44
|
+
}, [autoSize, value]);
|
|
45
|
+
|
|
46
|
+
// Text measured before web fonts load wraps differently, so measure again once they are ready
|
|
47
|
+
useComponentDidMount(() => {
|
|
48
|
+
if (!autoSize || !document.fonts) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
void document.fonts.ready.then(() => autosizeTextArea(inputRef.current));
|
|
52
|
+
});
|
|
53
|
+
|
|
22
54
|
const inputClasses = classNames(
|
|
23
55
|
'ds-textarea',
|
|
24
56
|
'ds-input',
|
|
@@ -28,19 +60,10 @@ export const TextArea = (props: TextAreaProps) => {
|
|
|
28
60
|
className,
|
|
29
61
|
);
|
|
30
62
|
|
|
31
|
-
const autosizeTextArea = () => {
|
|
32
|
-
if (inputRef.current) {
|
|
33
|
-
inputRef.current.style.height = 'auto';
|
|
34
|
-
const verticalPadding = 4;
|
|
35
|
-
const newHeight = inputRef.current.scrollHeight;
|
|
36
|
-
inputRef.current.style.height = `${newHeight + verticalPadding}px`;
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
63
|
const handleOnChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
41
64
|
onChange?.(e);
|
|
42
65
|
if (autoSize) {
|
|
43
|
-
autosizeTextArea();
|
|
66
|
+
autosizeTextArea(inputRef.current);
|
|
44
67
|
}
|
|
45
68
|
};
|
|
46
69
|
|
|
@@ -50,10 +73,13 @@ export const TextArea = (props: TextAreaProps) => {
|
|
|
50
73
|
className={inputClasses}
|
|
51
74
|
disabled={disabled}
|
|
52
75
|
onChange={handleOnChange}
|
|
76
|
+
value={value}
|
|
53
77
|
{...rest}
|
|
54
78
|
/>
|
|
55
79
|
);
|
|
56
|
-
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
TextArea.displayName = 'TextArea';
|
|
57
83
|
|
|
58
84
|
export namespace TextArea {
|
|
59
85
|
export type Props = TextAreaProps;
|
|
@@ -20,10 +20,11 @@ const DESCRIPTION_INTRO = [
|
|
|
20
20
|
'TimeInput is a flexible time-entry field that operates in **two distinct modes** depending on',
|
|
21
21
|
'whether the `options` prop is supplied.',
|
|
22
22
|
'',
|
|
23
|
-
'**Native mode** (no `options`) renders a
|
|
24
|
-
'
|
|
25
|
-
'
|
|
26
|
-
'
|
|
23
|
+
'**Native mode** (no `options`) renders a plain text field decorated with a clock icon. Typed input is',
|
|
24
|
+
'limited to digits and colons, so no browser time picker appears and every browser shows the same',
|
|
25
|
+
'single control. Like the other input components, it does not validate the value: range checks and',
|
|
26
|
+
'error messages belong to the consuming code, which presents them through `hasError` and `aria-invalid`.',
|
|
27
|
+
'This mode supports `granularity`, `className`, a forwarded `ref`, and `onChange`.',
|
|
27
28
|
'',
|
|
28
29
|
'**Combobox mode** (`options` provided) replaces the native input with a searchable Combobox.',
|
|
29
30
|
'The user can type to filter or click to select from a predefined list of time values. This mode',
|
|
@@ -37,8 +38,8 @@ const DESCRIPTION_INTRO = [
|
|
|
37
38
|
const USAGE_GUIDANCE = [
|
|
38
39
|
'### When to use',
|
|
39
40
|
'',
|
|
40
|
-
'- **
|
|
41
|
-
'
|
|
41
|
+
'- **Free-text time entry** — use native mode (the default). Validate and normalise the typed text in',
|
|
42
|
+
' your own code, and present errors with `hasError` and `aria-invalid`.',
|
|
42
43
|
'- **Fixed set of allowed times** — use combobox mode with `options`. Ideal for lesson periods,',
|
|
43
44
|
' appointment slots, or any scenario where only specific times are valid.',
|
|
44
45
|
'- **Inside a form** — always wrap in `<FormField inputType="time">` so label, error text, and',
|
|
@@ -52,7 +53,7 @@ const USAGE_GUIDANCE = [
|
|
|
52
53
|
'|---|---|',
|
|
53
54
|
'| Date + time combined | Separate `DateInput` and `TimeInput` fields |',
|
|
54
55
|
'| Free-form duration entry | `TextInput` with format hint |',
|
|
55
|
-
'| More than ~50 time options | Native mode
|
|
56
|
+
'| More than ~50 time options | Native mode, validating the range in your own code |',
|
|
56
57
|
'| Status or category selection | `SelectDropdown` with meaningful labels |',
|
|
57
58
|
].join('\n');
|
|
58
59
|
|
|
@@ -64,44 +65,46 @@ const DEVELOPER_NOTES = [
|
|
|
64
65
|
'',
|
|
65
66
|
'| Prop | Native mode | Combobox mode |',
|
|
66
67
|
'|---|---|---|',
|
|
67
|
-
'| `granularity` | Sets `
|
|
68
|
-
'| `min` / `max` |
|
|
68
|
+
'| `granularity` | Sets the placeholder to `HH:mm` or `HH:mm:ss` | **Ignored** |',
|
|
69
|
+
'| `min` / `max` / `step` | Passed to the `<input>` but a text field ignores them. Enforce ranges in your own code | **Ignored** — filter `options` array instead |',
|
|
69
70
|
'| `className` | Applied to the wrapper `<div>` | **Ignored** |',
|
|
70
|
-
'| `ref` (forwarded) | Attached to the `<input>`
|
|
71
|
+
'| `ref` (forwarded) | Attached to the visible text `<input>` | **Ignored** |',
|
|
71
72
|
'| `onChange` | Native `ChangeEvent<HTMLInputElement>` | **Ignored** — use `onValueChange` |',
|
|
72
73
|
'| `searchType` | **Ignored** | Controls `prefix`/`substring` matching |',
|
|
73
74
|
'| `highlightStringMatches` | **Ignored** | Bolds matched characters in dropdown |',
|
|
74
75
|
'| `onValueChange` | Fires with the string value | Fires with the string value |',
|
|
75
76
|
'',
|
|
76
|
-
'
|
|
77
|
-
'
|
|
77
|
+
'### Validation (native mode)',
|
|
78
|
+
'',
|
|
79
|
+
'TimeInput only filters characters: keystrokes and pasted text are limited to digits and colons, so',
|
|
80
|
+
'`abc` and `9.05` never reach the field. It does not parse, normalise or validate the result, so `25:00`',
|
|
81
|
+
'is emitted as typed. The component never sets `aria-invalid`, so range validation is the consumer\'s job:',
|
|
78
82
|
'',
|
|
79
83
|
'```tsx',
|
|
80
|
-
'// Standalone —
|
|
81
|
-
'<TimeInput hasError aria-invalid={
|
|
84
|
+
'// Standalone — validate in your code and present the result:',
|
|
85
|
+
'<TimeInput hasError={!isValid} aria-invalid={!isValid} aria-label="Start time" value={text} onValueChange={setText} />',
|
|
82
86
|
'',
|
|
83
|
-
'// Inside FormField —
|
|
84
|
-
'<FormField label="Start time" id="start" inputType="time" errorText="
|
|
87
|
+
'// Inside FormField — pass errorText and FormField sets hasError and aria-invalid:',
|
|
88
|
+
'<FormField label="Start time" id="start" inputType="time" errorText="Enter a valid time." inputProps={{}} />',
|
|
85
89
|
'```',
|
|
86
90
|
'',
|
|
87
|
-
'**`onValueChange` is the preferred callback** — it works in both modes and receives the
|
|
88
|
-
'
|
|
89
|
-
'mode and is not forwarded in combobox mode.',
|
|
91
|
+
'**`onValueChange` is the preferred callback** — it works in both modes and receives the string value.',
|
|
92
|
+
'`onChange` is only available in native mode and is not forwarded in combobox mode.',
|
|
90
93
|
'',
|
|
91
|
-
'**`TimeValue` is format-shaped
|
|
92
|
-
'
|
|
93
|
-
'time for `<input type="time">`, but the component itself does no validation.',
|
|
94
|
+
'**`TimeValue` is format-shaped.** The TypeScript type `` `${string}:${string}` `` accepts any string',
|
|
95
|
+
'containing a colon, so it does not guarantee a valid time.',
|
|
94
96
|
'',
|
|
95
97
|
'---',
|
|
96
98
|
'',
|
|
97
99
|
'### Accessibility',
|
|
98
100
|
'',
|
|
99
101
|
'- Always provide a visible label via `<FormField>` or `aria-label` / `aria-labelledby`.',
|
|
100
|
-
'- In native mode
|
|
101
|
-
'
|
|
102
|
+
'- In native mode the field is a text input, so screen readers announce the text exactly as typed.',
|
|
103
|
+
' Errors are announced only when the consumer sets `aria-invalid`. The clock button is a labelled',
|
|
104
|
+
' button ("Select time") that moves focus to the field and selects the hours.',
|
|
102
105
|
'- In combobox mode, the Combobox component implements the ARIA combobox pattern',
|
|
103
106
|
' (`role="combobox"`, `aria-expanded`, `aria-activedescendant`).',
|
|
104
|
-
'- The clock icon is decorative
|
|
107
|
+
'- The clock icon is decorative; the clock button is labelled "Select time".',
|
|
105
108
|
'- Pair `hasError` with `aria-invalid={true}` when used standalone — one is visual, the other',
|
|
106
109
|
' is for screen readers. `<FormField>` handles this automatically when `errorText` is set.',
|
|
107
110
|
'',
|
|
@@ -171,7 +174,7 @@ const meta = {
|
|
|
171
174
|
argTypes: {
|
|
172
175
|
'value': {
|
|
173
176
|
description: [
|
|
174
|
-
'Controlled value
|
|
177
|
+
'Controlled text value, limited to digits and colons. Pass `HH:MM` or `HH:MM:SS` for a time.',
|
|
175
178
|
'Pair with `onValueChange` to keep state in sync.',
|
|
176
179
|
'If `undefined`, the component manages its own state (uncontrolled).',
|
|
177
180
|
].join(' '),
|
|
@@ -194,7 +197,8 @@ const meta = {
|
|
|
194
197
|
},
|
|
195
198
|
'onValueChange': {
|
|
196
199
|
description: [
|
|
197
|
-
'Callback fired with the plain string value
|
|
200
|
+
'Callback fired with the plain string value whenever the committed value changes.',
|
|
201
|
+
'In native mode it is the digits and colons entered, with no parsing or range validation.',
|
|
198
202
|
'Works in **both** native and combobox modes.',
|
|
199
203
|
'Prefer this over the native `onChange` event for cross-mode compatibility.',
|
|
200
204
|
].join(' '),
|
|
@@ -207,8 +211,8 @@ const meta = {
|
|
|
207
211
|
'options': {
|
|
208
212
|
description: [
|
|
209
213
|
'Array of `TimeValue` strings to display as selectable options.',
|
|
210
|
-
'Providing this prop switches to **combobox mode** — the
|
|
211
|
-
'replaced by a searchable Combobox. Omit to
|
|
214
|
+
'Providing this prop switches to **combobox mode** — the text field is',
|
|
215
|
+
'replaced by a searchable Combobox. Omit to type a time into the text field.',
|
|
212
216
|
].join(' '),
|
|
213
217
|
control: 'object',
|
|
214
218
|
table: {
|
|
@@ -219,7 +223,7 @@ const meta = {
|
|
|
219
223
|
'granularity': {
|
|
220
224
|
description: [
|
|
221
225
|
'**Native mode only.** Controls the precision of the time input.',
|
|
222
|
-
'`"second"` sets
|
|
226
|
+
'`"second"` sets the placeholder to `HH:mm:ss`.',
|
|
223
227
|
'Silently ignored when `options` is provided.',
|
|
224
228
|
].join(' '),
|
|
225
229
|
control: { type: 'select' },
|
|
@@ -257,7 +261,7 @@ const meta = {
|
|
|
257
261
|
'hasError': {
|
|
258
262
|
description: [
|
|
259
263
|
'Applies error-state visual styling (red border).',
|
|
260
|
-
'Does **not** set `aria-invalid`
|
|
264
|
+
'Does **not** set `aria-invalid` for errors you supply when used standalone outside FormField.',
|
|
261
265
|
'Always pair with `aria-invalid={true}` for screen-reader coverage.',
|
|
262
266
|
].join(' '),
|
|
263
267
|
control: 'boolean',
|
|
@@ -292,7 +296,7 @@ const meta = {
|
|
|
292
296
|
},
|
|
293
297
|
},
|
|
294
298
|
'placeholder': {
|
|
295
|
-
description: 'Placeholder text shown
|
|
299
|
+
description: 'Placeholder text shown while the field is empty, e.g. `HH:mm`. It disappears once a value is entered.',
|
|
296
300
|
control: 'text',
|
|
297
301
|
table: {
|
|
298
302
|
type: { summary: 'string' },
|
|
@@ -311,8 +315,7 @@ const meta = {
|
|
|
311
315
|
'aria-invalid': {
|
|
312
316
|
description: [
|
|
313
317
|
'Marks the input as invalid for screen readers.',
|
|
314
|
-
'
|
|
315
|
-
'when `errorText` is provided.',
|
|
318
|
+
'Never set by TimeInput. Set it when validating the text yourself, or let FormField set it when `errorText` is provided.',
|
|
316
319
|
].join(' '),
|
|
317
320
|
control: 'boolean',
|
|
318
321
|
table: {
|
|
@@ -409,26 +412,6 @@ const GranularitySecondTemplate = () => {
|
|
|
409
412
|
);
|
|
410
413
|
};
|
|
411
414
|
|
|
412
|
-
const WithMinMaxBoundsTemplate = () => {
|
|
413
|
-
const [value, setValue] = useState<TimeValue | ''>('');
|
|
414
|
-
return (
|
|
415
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-small)', maxWidth: '280px' }}>
|
|
416
|
-
<TimeInput
|
|
417
|
-
min="08:00"
|
|
418
|
-
max="17:00"
|
|
419
|
-
value={value}
|
|
420
|
-
onValueChange={v => setValue(v as TimeValue | '')}
|
|
421
|
-
id="bounded-time"
|
|
422
|
-
name="school-time"
|
|
423
|
-
aria-label="School hours time"
|
|
424
|
-
/>
|
|
425
|
-
<p style={{ margin: 0, color: 'var(--color-grey-600)' }}>
|
|
426
|
-
Restricted to school hours: 08:00 – 17:00
|
|
427
|
-
</p>
|
|
428
|
-
</div>
|
|
429
|
-
);
|
|
430
|
-
};
|
|
431
|
-
|
|
432
415
|
const WithOptionsTemplate = () => {
|
|
433
416
|
const [value, setValue] = useState<TimeValue | ''>('');
|
|
434
417
|
return (
|
|
@@ -697,39 +680,7 @@ export default PreciseTimeInputExample;
|
|
|
697
680
|
},
|
|
698
681
|
render: () => <GranularitySecondTemplate />,
|
|
699
682
|
},
|
|
700
|
-
'Setting `granularity="second"`
|
|
701
|
-
);
|
|
702
|
-
|
|
703
|
-
export const WithMinMaxBounds: Story = withDescription(
|
|
704
|
-
{
|
|
705
|
-
parameters: {
|
|
706
|
-
controls: { disable: true },
|
|
707
|
-
docs: {
|
|
708
|
-
source: {
|
|
709
|
-
language: 'tsx',
|
|
710
|
-
code: `
|
|
711
|
-
import { TimeInput, type TimeValue } from '@arbor-education/design-system.components';
|
|
712
|
-
|
|
713
|
-
function BoundedTimeInputExample() {
|
|
714
|
-
return (
|
|
715
|
-
<TimeInput
|
|
716
|
-
min="08:00"
|
|
717
|
-
max="17:00"
|
|
718
|
-
id="school-time"
|
|
719
|
-
name="school-time"
|
|
720
|
-
aria-label="School hours time"
|
|
721
|
-
/>
|
|
722
|
-
);
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
export default BoundedTimeInputExample;
|
|
726
|
-
`.trim(),
|
|
727
|
-
},
|
|
728
|
-
},
|
|
729
|
-
},
|
|
730
|
-
render: () => <WithMinMaxBoundsTemplate />,
|
|
731
|
-
},
|
|
732
|
-
'The `min` and `max` props restrict the selectable range in the native browser picker to school hours. **Native mode only** — in combobox mode, restrict available times by filtering the `options` array instead.',
|
|
683
|
+
'Setting `granularity="second"` sets the placeholder to `HH:mm:ss`. Seconds are typed by the user, and only digits and colons are accepted. **Native mode only** — silently ignored when `options` is provided.',
|
|
733
684
|
);
|
|
734
685
|
|
|
735
686
|
export const WithError: Story = withDescription(
|
|
@@ -19,17 +19,22 @@ describe('TimeInput', () => {
|
|
|
19
19
|
vi.restoreAllMocks();
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
test('renders a
|
|
22
|
+
test('renders a text input so no browser picker is drawn', () => {
|
|
23
23
|
render(<TimeInput aria-label="Start time" />);
|
|
24
|
-
const input = screen.getByLabelText('Start time');
|
|
25
24
|
|
|
26
|
-
expect(
|
|
27
|
-
|
|
25
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('type', 'text');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('shows HH:mm as the placeholder by default', () => {
|
|
29
|
+
render(<TimeInput aria-label="Start time" />);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm');
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
test('
|
|
34
|
+
test('shows HH:mm:ss as the placeholder when granularity is second', () => {
|
|
31
35
|
render(<TimeInput aria-label="Start time" granularity="second" />);
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm:ss');
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
test('native mode emits the string value when changed', () => {
|
|
@@ -43,6 +48,70 @@ describe('TimeInput', () => {
|
|
|
43
48
|
expect(onValueChange).toHaveBeenCalledWith('14:30');
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
test('blocks keystrokes other than digits and colons', async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const onValueChange = vi.fn();
|
|
54
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
55
|
+
|
|
56
|
+
await user.type(screen.getByLabelText('Start time'), '9.7x:a');
|
|
57
|
+
|
|
58
|
+
expect(onValueChange).toHaveBeenLastCalledWith('97:');
|
|
59
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('97:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('strips non-time characters from pasted text', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
const onValueChange = vi.fn();
|
|
65
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
66
|
+
const input = screen.getByLabelText('Start time');
|
|
67
|
+
|
|
68
|
+
await user.click(input);
|
|
69
|
+
await user.paste('abc14:30');
|
|
70
|
+
|
|
71
|
+
expect(onValueChange).toHaveBeenLastCalledWith('14:30');
|
|
72
|
+
expect(input).toHaveValue('14:30');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a typed colon is accepted', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(<TimeInput aria-label="Start time" />);
|
|
78
|
+
|
|
79
|
+
await user.type(screen.getByLabelText('Start time'), '14:30');
|
|
80
|
+
|
|
81
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('14:30');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('does not set aria-invalid itself, so validation stays with the consumer', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
render(<TimeInput aria-label="Start time" />);
|
|
87
|
+
const input = screen.getByLabelText('Start time');
|
|
88
|
+
|
|
89
|
+
await user.type(input, 'abc');
|
|
90
|
+
|
|
91
|
+
expect(input).not.toHaveAttribute('aria-invalid');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('applies error styling from hasError and reflects aria-invalid passed by the consumer', () => {
|
|
95
|
+
render(<TimeInput aria-label="Start time" hasError aria-invalid />);
|
|
96
|
+
const input = screen.getByLabelText('Start time');
|
|
97
|
+
|
|
98
|
+
expect(input).toHaveClass('ds-input--error');
|
|
99
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('submits the typed text under the field name', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(
|
|
105
|
+
<form aria-label="Schedule">
|
|
106
|
+
<TimeInput aria-label="Start time" name="start" />
|
|
107
|
+
</form>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await user.type(screen.getByLabelText('Start time'), '9:05');
|
|
111
|
+
|
|
112
|
+
expect(new FormData(screen.getByRole<HTMLFormElement>('form')).get('start')).toBe('9:05');
|
|
113
|
+
});
|
|
114
|
+
|
|
46
115
|
test('clicking the clock icon focuses the input and selects the hour segment when supported', async () => {
|
|
47
116
|
const user = userEvent.setup();
|
|
48
117
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange').mockImplementation(() => {});
|
|
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|
|
2
2
|
import { Combobox } from 'Components/combobox/Combobox';
|
|
3
3
|
import type { ComboboxOption, ComboboxSearchType } from 'Components/combobox/types';
|
|
4
4
|
import { Icon } from 'Components/icon/Icon';
|
|
5
|
-
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
|
|
5
|
+
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes, type KeyboardEvent } from 'react';
|
|
6
6
|
|
|
7
7
|
/** Expected string `HH:MM` or `HH:MM:SS`; format-shaped versus strict time validation. */
|
|
8
8
|
export type TimeValue = `${string}:${string}` | `${string}:${string}:${string}`;
|
|
@@ -22,6 +22,14 @@ export type TimeInputProps = {
|
|
|
22
22
|
|
|
23
23
|
const clockIcon = <Icon name="clock-3" size={16} />;
|
|
24
24
|
|
|
25
|
+
const TIME_CHARACTER_PATTERN = /^[0-9:]$/;
|
|
26
|
+
const NON_TIME_CHARACTERS_PATTERN = /[^0-9:]/g;
|
|
27
|
+
|
|
28
|
+
const NATIVE_PLACEHOLDER_BY_GRANULARITY: Record<TimeGranularity, string> = {
|
|
29
|
+
minute: 'HH:mm',
|
|
30
|
+
second: 'HH:mm:ss',
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
/** The forwarded ref is attached in native mode but when `options` is provided, no input ref is exposed. */
|
|
26
34
|
export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, ref) => {
|
|
27
35
|
const {
|
|
@@ -32,6 +40,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
32
40
|
searchType = 'prefix',
|
|
33
41
|
highlightStringMatches = false,
|
|
34
42
|
onChange,
|
|
43
|
+
onKeyDown,
|
|
35
44
|
className,
|
|
36
45
|
disabled = false,
|
|
37
46
|
id,
|
|
@@ -76,8 +85,22 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
76
85
|
} as ChangeEvent<HTMLInputElement>);
|
|
77
86
|
}, [isControlled, onChange, onValueChange]);
|
|
78
87
|
|
|
88
|
+
const handleNativeKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
|
89
|
+
const isShortcut = event.ctrlKey || event.metaKey || event.altKey;
|
|
90
|
+
const isPrintable = event.key.length === 1;
|
|
91
|
+
if (isPrintable && !isShortcut && !TIME_CHARACTER_PATTERN.test(event.key)) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
}
|
|
94
|
+
onKeyDown?.(event);
|
|
95
|
+
}, [onKeyDown]);
|
|
96
|
+
|
|
79
97
|
const handleNativeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
80
|
-
|
|
98
|
+
const { value } = event.currentTarget;
|
|
99
|
+
const timeCharacters = value.replace(NON_TIME_CHARACTERS_PATTERN, '');
|
|
100
|
+
if (timeCharacters !== value) {
|
|
101
|
+
event.currentTarget.value = timeCharacters;
|
|
102
|
+
}
|
|
103
|
+
updateValue(timeCharacters, event);
|
|
81
104
|
}, [updateValue]);
|
|
82
105
|
|
|
83
106
|
const focusAndSelectHours = useCallback(() => {
|
|
@@ -87,11 +110,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
input.focus();
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
input.setSelectionRange(0, 2);
|
|
93
|
-
}
|
|
94
|
-
catch { /* Some browsers do not expose selection APIs on time inputs. */ }
|
|
113
|
+
input.setSelectionRange(0, 2);
|
|
95
114
|
}, []);
|
|
96
115
|
|
|
97
116
|
const timeOptions = useMemo<ComboboxOption[]>(
|
|
@@ -139,18 +158,18 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
139
158
|
ref={setRefs}
|
|
140
159
|
id={id}
|
|
141
160
|
name={name}
|
|
142
|
-
type="
|
|
161
|
+
type="text"
|
|
143
162
|
value={currentValue}
|
|
144
|
-
step={granularity === 'second' ? 1 : 60}
|
|
145
163
|
disabled={disabled}
|
|
146
164
|
aria-describedby={ariaDescribedBy}
|
|
147
165
|
aria-invalid={ariaInvalid}
|
|
148
166
|
aria-label={ariaLabel}
|
|
149
|
-
placeholder={placeholder}
|
|
167
|
+
placeholder={placeholder ?? NATIVE_PLACEHOLDER_BY_GRANULARITY[granularity]}
|
|
150
168
|
className={classNames('ds-input', 'ds-input--M', 'ds-time-input__input', {
|
|
151
169
|
'ds-input--error': hasError,
|
|
152
170
|
})}
|
|
153
171
|
onChange={handleNativeChange}
|
|
172
|
+
onKeyDown={handleNativeKeyDown}
|
|
154
173
|
/>
|
|
155
174
|
<button
|
|
156
175
|
type="button"
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
.ds-time-input__input {
|
|
7
|
-
appearance: none;
|
|
8
7
|
padding-right: calc(var(--form-field-text-medium-height) + var(--spacing-small));
|
|
9
|
-
|
|
10
|
-
&::-webkit-calendar-picker-indicator {
|
|
11
|
-
display: none;
|
|
12
|
-
appearance: none;
|
|
13
|
-
}
|
|
14
8
|
}
|
|
15
9
|
|
|
16
10
|
.ds-time-input__icon-button {
|