@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.
Files changed (111) 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.d.ts +1 -0
  24. package/dist/components/formField/FormField.d.ts.map +1 -1
  25. package/dist/components/formField/FormField.js +2 -2
  26. package/dist/components/formField/FormField.js.map +1 -1
  27. package/dist/components/formField/FormField.stories.d.ts +9 -0
  28. package/dist/components/formField/FormField.stories.d.ts.map +1 -1
  29. package/dist/components/formField/FormField.stories.js +28 -16
  30. package/dist/components/formField/FormField.stories.js.map +1 -1
  31. package/dist/components/formField/FormField.test.js +23 -1
  32. package/dist/components/formField/FormField.test.js.map +1 -1
  33. package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
  34. package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
  35. package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
  36. package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
  37. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
  38. package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
  39. package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
  40. package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
  41. package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
  42. package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
  43. package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
  44. package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
  45. package/dist/components/formField/inputs/time/TimeInput.js +24 -8
  46. package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
  47. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
  48. package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
  49. package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
  50. package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
  51. package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
  52. package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
  53. package/dist/components/kvpList/KVPList.d.ts +2 -1
  54. package/dist/components/kvpList/KVPList.d.ts.map +1 -1
  55. package/dist/components/kvpList/KVPList.js +1 -1
  56. package/dist/components/kvpList/KVPList.js.map +1 -1
  57. package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
  58. package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
  59. package/dist/components/kvpList/KVPList.stories.js +35 -0
  60. package/dist/components/kvpList/KVPList.stories.js.map +1 -1
  61. package/dist/components/kvpList/KVPList.test.js +16 -0
  62. package/dist/components/kvpList/KVPList.test.js.map +1 -1
  63. package/dist/components/row/Row.d.ts +1 -0
  64. package/dist/components/row/Row.d.ts.map +1 -1
  65. package/dist/components/row/Row.js +3 -2
  66. package/dist/components/row/Row.js.map +1 -1
  67. package/dist/components/row/Row.stories.d.ts +13 -0
  68. package/dist/components/row/Row.stories.d.ts.map +1 -1
  69. package/dist/components/row/Row.stories.js +53 -0
  70. package/dist/components/row/Row.stories.js.map +1 -1
  71. package/dist/components/row/Row.test.js +40 -0
  72. package/dist/components/row/Row.test.js.map +1 -1
  73. package/dist/index.css +10 -5
  74. package/dist/index.css.map +1 -1
  75. package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
  76. package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
  77. package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
  78. package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
  79. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
  80. package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
  81. package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
  82. package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
  83. package/package.json +1 -1
  84. package/src/components/banner/banner.scss +4 -0
  85. package/src/components/datePicker/DatePicker.test.tsx +48 -0
  86. package/src/components/datePicker/DatePicker.tsx +2 -1
  87. package/src/components/datePicker/dateInputUtils.test.ts +29 -0
  88. package/src/components/datePicker/dateInputUtils.ts +24 -0
  89. package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
  90. package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
  91. package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
  92. package/src/components/formField/FormField.stories.tsx +32 -18
  93. package/src/components/formField/FormField.test.tsx +28 -1
  94. package/src/components/formField/FormField.tsx +3 -2
  95. package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
  96. package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
  97. package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
  98. package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
  99. package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
  100. package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
  101. package/src/components/formField/inputs/time/timeInput.scss +0 -6
  102. package/src/components/kvpList/KVPList.stories.tsx +54 -0
  103. package/src/components/kvpList/KVPList.test.tsx +52 -0
  104. package/src/components/kvpList/KVPList.tsx +10 -1
  105. package/src/components/kvpList/kvpList.scss +4 -0
  106. package/src/components/row/Row.stories.tsx +63 -0
  107. package/src/components/row/Row.test.tsx +59 -0
  108. package/src/components/row/Row.tsx +4 -0
  109. package/src/components/row/row.scss +5 -2
  110. package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
  111. package/src/utils/hooks/useIsControlledOnceSet.ts +13 -0
@@ -9,6 +9,33 @@ describe('FormField component', () => {
9
9
  expect(screen.getByLabelText('Email')).toBeInTheDocument();
10
10
  });
11
11
 
12
+ test('appends the optional suffix to the label when optional is true', () => {
13
+ render(<FormField label="Middle name" id="niceid" optional />);
14
+ expect(screen.getByLabelText('Middle name (optional)')).toBeInTheDocument();
15
+ });
16
+
17
+ test('leaves the label unchanged when optional is false', () => {
18
+ render(<FormField label="Surname" id="niceid" optional={false} />);
19
+ expect(screen.getByLabelText('Surname')).toBeInTheDocument();
20
+ expect(screen.queryByText(/optional/)).not.toBeInTheDocument();
21
+ });
22
+
23
+ test('leaves the label unchanged when optional is not provided', () => {
24
+ render(<FormField label="Surname" id="niceid" />);
25
+ expect(screen.getByLabelText('Surname')).toBeInTheDocument();
26
+ expect(screen.queryByText(/optional/)).not.toBeInTheDocument();
27
+ });
28
+
29
+ test('does not render an asterisk for fields that are not optional', () => {
30
+ render(<FormField label="Surname" id="niceid" />);
31
+ expect(screen.queryByText(/\*/)).not.toBeInTheDocument();
32
+ });
33
+
34
+ test('still passes native required from inputProps to the input', () => {
35
+ render(<FormField label="Surname" id="niceid" inputProps={{ required: true }} />);
36
+ expect(screen.getByLabelText('Surname')).toBeRequired();
37
+ });
38
+
12
39
  test('renders helper link when provided', () => {
13
40
  render(<FormField helperLinkText="This is helper text" helperLinkUrl="https://www.google.com" inputProps={{ placeholder: 'Enter text' }} />);
14
41
  expect(screen.getByText('This is helper text')).toBeInTheDocument();
@@ -48,7 +75,7 @@ describe('FormField component', () => {
48
75
  test('renders a time input when inputType is time', () => {
49
76
  render(<FormField id="niceid" inputType="time" inputProps={{ 'aria-label': 'Start time' }} />);
50
77
  const input = screen.getByLabelText('Start time');
51
- expect(input).toHaveAttribute('type', 'time');
78
+ expect(input).toHaveClass('ds-time-input__input');
52
79
  });
53
80
 
54
81
  test('renders a colour picker dropdown when inputType is colourPicker', () => {
@@ -39,6 +39,7 @@ export type FormFieldProps = {
39
39
  helperLinkText?: string;
40
40
  helperLinkUrl?: string;
41
41
  errorText?: string;
42
+ optional?: boolean;
42
43
  } & (
43
44
  | { inputType?: 'text'; inputProps?: TextInput.Props }
44
45
  | { inputType?: 'textarea'; inputProps?: TextArea.Props }
@@ -58,7 +59,7 @@ export type FormFieldProps = {
58
59
  );
59
60
 
60
61
  export const FormField = (props: FormFieldProps) => {
61
- const { className, label, id, inputType = 'text', helperLinkText, helperLinkUrl, errorText, inputProps, fieldDescription } = props;
62
+ const { className, label, id, inputType = 'text', helperLinkText, helperLinkUrl, errorText, inputProps, fieldDescription, optional } = props;
62
63
  const classes = classNames('ds-form-field', className);
63
64
  const describedByIds = [];
64
65
  if (fieldDescription) {
@@ -81,7 +82,7 @@ export const FormField = (props: FormFieldProps) => {
81
82
  <div className={classes}>
82
83
  {label && (
83
84
  <Label htmlFor={id} id={labelId}>
84
- {label}
85
+ {optional ? `${label} (optional)` : label}
85
86
  </Label>
86
87
  )}
87
88
  {fieldDescription && (
@@ -9,7 +9,8 @@ import {
9
9
  Title,
10
10
  } from '@storybook/addon-docs/blocks';
11
11
  import { useState } from 'react';
12
- import { fn } from 'storybook/test';
12
+ import { expect, fn, within } from 'storybook/test';
13
+ import { Button } from 'Components/button/Button';
13
14
  import { FormField } from 'Components/formField/FormField';
14
15
  import { TextArea } from './TextArea.js';
15
16
 
@@ -57,11 +58,11 @@ const DEVELOPER_NOTES = [
57
58
  '### Critical gotchas',
58
59
  '',
59
60
  '#### 1. `autoSize` mechanics',
60
- 'When `autoSize={true}` (the default), the component reads `scrollHeight` on every `onChange` event',
61
- 'and sets `height = scrollHeight + 4px` (vertical padding compensation).',
62
- 'The textarea still starts at the height implied by the `rows` prop — growth begins after the first keystroke.',
63
- 'Importantly, `autoSize` is triggered by `onChange`, which means it only fires when the user types.',
64
- 'Setting a large `defaultValue` at mount will NOT auto-grow; call layout imperatively if needed.',
61
+ 'When `autoSize={true}` (the default), the component reads `scrollHeight` and sets',
62
+ '`height = scrollHeight + 4px` (vertical padding compensation).',
63
+ 'It does this on mount, whenever the controlled `value` changes, and on every `onChange` event,',
64
+ 'so a field pre-populated through `value` or `defaultValue` opens fitted to its content.',
65
+ '`rows` is the minimum starting height for empty or short content.',
65
66
  '',
66
67
  '#### 2. CSS `resize` is NOT controlled by this component',
67
68
  'The browser default (`resize: both` or `resize: vertical` depending on browser) applies.',
@@ -75,11 +76,9 @@ const DEVELOPER_NOTES = [
75
76
  '<TextArea className="my-fixed-textarea" />',
76
77
  '```',
77
78
  '',
78
- '#### 3. No `forwardRef` — consumers cannot attach a ref',
79
- 'TextArea uses an internal `ref` for `autoSize` calculations.',
80
- 'It is NOT a `forwardRef` component — you cannot attach a `ref` to it from the outside.',
81
- 'If you need imperative focus control, wrap the TextArea and use a separate DOM query,',
82
- 'or request `forwardRef` support as a library enhancement.',
79
+ '#### 3. `ref` resolves to the native `<textarea>`',
80
+ 'TextArea is a `forwardRef` component. A `ref` you pass resolves to the underlying `HTMLTextAreaElement`,',
81
+ 'for example to call `focus()` or `select()`. Attaching a ref does not interfere with `autoSize`.',
83
82
  '',
84
83
  '#### 4. `size` prop is intentionally omitted',
85
84
  'The HTML `size` attribute is omitted from the props (the TypeScript type uses `Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "size">`).',
@@ -277,8 +276,7 @@ const meta = {
277
276
  description: [
278
277
  'Uncontrolled initial value. The textarea manages its own state after mount.',
279
278
  'Do not combine with `value` — use one or the other.',
280
- 'Note: setting a large `defaultValue` at mount will NOT trigger `autoSize`.',
281
- 'The textarea starts at the height set by `rows` regardless of the initial value length.',
279
+ 'With `autoSize` enabled, the textarea grows on mount to fit a long `defaultValue`.',
282
280
  ].join(' '),
283
281
  table: {
284
282
  type: { summary: 'string' },
@@ -642,6 +640,56 @@ export const ReadOnly: Story = withDescription(
642
640
  ].join(' '),
643
641
  );
644
642
 
643
+ const PRE_POPULATED_NOTES = 'Pupil has a documented nut allergy (anaphylaxis risk). EpiPen is held in the school office and a second pen travels with the pupil on trips. All catering staff have been briefed. Parents have supplied an updated care plan, which is reviewed each term with the school nurse.';
644
+
645
+ export const PrePopulated: Story = withDescription(
646
+ {
647
+ render: () => (
648
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
649
+ <label htmlFor="medical-notes-prepopulated" style={{ color: 'var(--color-grey-600)' }}>
650
+ Medical notes
651
+ </label>
652
+ <TextArea id="medical-notes-prepopulated" rows={2} defaultValue={PRE_POPULATED_NOTES} />
653
+ </div>
654
+ ),
655
+ play: async ({ canvasElement }) => {
656
+ const canvas = within(canvasElement);
657
+ const textArea = canvas.getByRole('textbox', { name: 'Medical notes' });
658
+
659
+ await expect(textArea.scrollHeight).toBeLessThanOrEqual(textArea.clientHeight);
660
+ },
661
+ },
662
+ [
663
+ 'A field opened with stored content longer than its `rows` height.',
664
+ 'With `autoSize` enabled (the default) the textarea fits the content on mount, so nothing is hidden behind an internal scrollbar.',
665
+ ].join(' '),
666
+ );
667
+
668
+ const ExternallyUpdatedValueTemplate = () => {
669
+ const [notes, setNotes] = useState('Allergy reviewed.');
670
+ return (
671
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-xsmall)' }}>
672
+ <label htmlFor="care-plan-notes" style={{ color: 'var(--color-grey-600)' }}>
673
+ Care plan notes
674
+ </label>
675
+ <TextArea id="care-plan-notes" rows={2} value={notes} onChange={e => setNotes(e.currentTarget.value)} />
676
+ <Button variant="secondary" size="S" onClick={() => setNotes(PRE_POPULATED_NOTES)}>
677
+ Load care plan template
678
+ </Button>
679
+ </div>
680
+ );
681
+ };
682
+
683
+ export const ExternallyUpdatedValue: Story = withDescription(
684
+ {
685
+ render: () => <ExternallyUpdatedValueTemplate />,
686
+ },
687
+ [
688
+ 'A controlled textarea whose `value` is replaced from outside, here by loading a template.',
689
+ 'With `autoSize` enabled the height refits to the new content without the user typing.',
690
+ ].join(' '),
691
+ );
692
+
645
693
  export const WithFormField: Story = withDescription(
646
694
  {
647
695
  render: () => (
@@ -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(