@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.
Files changed (115) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
  3. package/dist/components/datePicker/DatePicker.js +2 -1
  4. package/dist/components/datePicker/DatePicker.js.map +1 -1
  5. package/dist/components/datePicker/DatePicker.test.js +32 -0
  6. package/dist/components/datePicker/DatePicker.test.js.map +1 -1
  7. package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
  8. package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
  9. package/dist/components/datePicker/dateInputUtils.js +20 -0
  10. package/dist/components/datePicker/dateInputUtils.js.map +1 -1
  11. package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
  12. package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
  13. package/dist/components/datePicker/dateInputUtils.test.js +28 -0
  14. package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
  15. package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
  16. package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
  17. package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
  18. package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
  19. package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
  20. package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
  21. package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
  22. package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
  23. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  24. package/dist/components/formField/FormField.stories.js +3 -4
  25. package/dist/components/formField/FormField.stories.js.map +1 -1
  26. package/dist/components/formField/FormField.test.js +1 -1
  27. package/dist/components/formField/FormField.test.js.map +1 -1
  28. package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
  29. package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
  30. package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
  31. package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
  32. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
  33. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  34. package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
  35. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  36. package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
  37. package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
  38. package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
  39. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
  40. package/dist/components/formField/inputs/time/TimeInput.js +24 -8
  41. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
  42. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
  43. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
  44. package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
  45. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
  46. package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
  47. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
  48. package/dist/components/kvpList/KVPList.d.ts +2 -1
  49. package/dist/components/kvpList/KVPList.d.ts.map +1 -1
  50. package/dist/components/kvpList/KVPList.js +1 -1
  51. package/dist/components/kvpList/KVPList.js.map +1 -1
  52. package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
  53. package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
  54. package/dist/components/kvpList/KVPList.stories.js +35 -0
  55. package/dist/components/kvpList/KVPList.stories.js.map +1 -1
  56. package/dist/components/kvpList/KVPList.test.js +16 -0
  57. package/dist/components/kvpList/KVPList.test.js.map +1 -1
  58. package/dist/components/row/Row.d.ts +1 -0
  59. package/dist/components/row/Row.d.ts.map +1 -1
  60. package/dist/components/row/Row.js +3 -2
  61. package/dist/components/row/Row.js.map +1 -1
  62. package/dist/components/row/Row.stories.d.ts +13 -0
  63. package/dist/components/row/Row.stories.d.ts.map +1 -1
  64. package/dist/components/row/Row.stories.js +53 -0
  65. package/dist/components/row/Row.stories.js.map +1 -1
  66. package/dist/components/row/Row.test.js +40 -0
  67. package/dist/components/row/Row.test.js.map +1 -1
  68. package/dist/components/table/cellEditors/NumberCellEditor.d.ts +2 -2
  69. package/dist/components/table/cellEditors/NumberCellEditor.d.ts.map +1 -1
  70. package/dist/components/table/cellEditors/NumberCellEditor.js +1 -1
  71. package/dist/components/table/cellEditors/NumberCellEditor.js.map +1 -1
  72. package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts +2 -0
  73. package/dist/components/table/cellEditors/NumberCellEditor.test.d.ts.map +1 -0
  74. package/dist/components/table/cellEditors/NumberCellEditor.test.js +57 -0
  75. package/dist/components/table/cellEditors/NumberCellEditor.test.js.map +1 -0
  76. package/dist/index.css +10 -5
  77. package/dist/index.css.map +1 -1
  78. package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
  79. package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
  80. package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
  81. package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
  82. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
  83. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
  84. package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
  85. package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
  86. package/package.json +1 -1
  87. package/src/components/banner/banner.scss +4 -0
  88. package/src/components/datePicker/DatePicker.test.tsx +48 -0
  89. package/src/components/datePicker/DatePicker.tsx +2 -1
  90. package/src/components/datePicker/dateInputUtils.test.ts +29 -0
  91. package/src/components/datePicker/dateInputUtils.ts +24 -0
  92. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
  93. package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
  94. package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
  95. package/src/components/formField/FormField.stories.tsx +3 -4
  96. package/src/components/formField/FormField.test.tsx +1 -1
  97. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
  98. package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
  99. package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
  100. package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
  101. package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
  102. package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
  103. package/src/components/formField/inputs/time/timeInput.scss +0 -6
  104. package/src/components/kvpList/KVPList.stories.tsx +54 -0
  105. package/src/components/kvpList/KVPList.test.tsx +52 -0
  106. package/src/components/kvpList/KVPList.tsx +10 -1
  107. package/src/components/kvpList/kvpList.scss +4 -0
  108. package/src/components/row/Row.stories.tsx +63 -0
  109. package/src/components/row/Row.test.tsx +59 -0
  110. package/src/components/row/Row.tsx +4 -0
  111. package/src/components/row/row.scss +5 -2
  112. package/src/components/table/cellEditors/NumberCellEditor.test.tsx +74 -0
  113. package/src/components/table/cellEditors/NumberCellEditor.tsx +4 -4
  114. package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
  115. package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
@@ -1,9 +1,26 @@
1
- import { expect, describe, test, vi } from 'vitest';
2
- import { fireEvent, render, screen } from '@testing-library/react';
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
- export const TextArea = (props: TextAreaProps) => {
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 browser-native `<input type="time">` decorated with a',
24
- 'clock icon. The browser handles the picker UI, spin-button keyboard navigation, and locale-aware',
25
- 'display. This mode supports `min`, `max`, `granularity`, `className`, a forwarded `ref`, and the',
26
- 'native `onChange` event.',
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
- '- **Any valid time** — use native mode (the default). The browser enforces valid time entry and',
41
- ' supports `min`/`max` bounds for school hours (e.g. `min="08:00" max="17:00"`).',
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 with `min`/`max` bounds |',
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 `step={1}` for second-level precision | **Ignored** |',
68
- '| `min` / `max` | Restricts picker range | **Ignored** — filter `options` array instead |',
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>` element | **Ignored** |',
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
- '**`aria-invalid` is NOT automatic.** When using TimeInput standalone (outside FormField),',
77
- '`hasError` only applies visual error styling. It does NOT set `aria-invalid`:',
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 — set aria-invalid yourself:',
81
- '<TimeInput hasError aria-invalid={true} aria-label="Start time" />',
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 — aria-invalid is set automatically when errorText is present:',
84
- '<FormField label="Start time" id="start" inputType="time" errorText="Required." inputProps={{}} />',
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 plain',
88
- 'string value (`HH:MM` or `HH:MM:SS`). The native `onChange` event is only available in native',
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, not strictly validated.** The TypeScript type',
92
- '`` `${string}:${string}` `` accepts `"99:99"` without complaint. The browser rejects it at render',
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, the browser\'s built-in time picker is fully keyboard-navigable (arrow keys',
101
- ' cycle through the hour/minute/second segments; AM/PM toggles with A/P).',
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 and carries `aria-hidden="true"`.',
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 in `HH:MM` or `HH:MM:SS` format.',
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 on every change.',
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 native `<input type="time">` is',
211
- 'replaced by a searchable Combobox. Omit to use the browser-native time picker.',
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 `step={1}` so the seconds segment is visible.',
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` automatically when used standalone outside FormField.',
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 when no value is selected. Most useful in combobox mode.',
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
- 'Must be set manually when used standalone — FormField sets this automatically',
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"` adds a seconds segment to the native time input (`step={1}`). Use this for stopwatch-style timing or precise event logging. **Native mode only** — silently ignored when `options` is provided.',
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 native time input by default', () => {
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(input).toHaveAttribute('type', 'time');
27
- expect(input).toHaveAttribute('step', '60');
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('uses one-second stepping when granularity is second', () => {
34
+ test('shows HH:mm:ss as the placeholder when granularity is second', () => {
31
35
  render(<TimeInput aria-label="Start time" granularity="second" />);
32
- expect(screen.getByLabelText('Start time')).toHaveAttribute('step', '1');
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
- updateValue(event.currentTarget.value, event);
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="time"
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 {