@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
@@ -33,6 +33,7 @@ const TERM_PROPS = [
33
33
  '| Prop | Type | Default | Description |',
34
34
  '|---|---|---|---|',
35
35
  '| `children` | `ReactNode` | — | The key/label text |',
36
+ '| `prominence` | `\'neutral\' \\| \'strong\'` | `\'neutral\'` | `neutral` = body text; `strong` = bold body weight, e.g. a totals row label |',
36
37
  '| `className` | `string` | — | Additional CSS classes on the `<dt>` element |',
37
38
  '| `...HTMLAttributes<dt>` | | | All standard element attributes forwarded |',
38
39
  ].join('\n');
@@ -372,6 +373,59 @@ export default StrongProminenceExample;
372
373
  '`prominence="strong"` applies h2-scale typography to the definition — rendered as `<dd>` semantically but visually dominant. Use for the primary metric value in a list; `"neutral"` for supporting context.',
373
374
  );
374
375
 
376
+ export const TotalsRow: Story = withDescription(
377
+ {
378
+ parameters: {
379
+ controls: { disable: true },
380
+ docs: {
381
+ source: {
382
+ language: 'tsx',
383
+ code: `
384
+ import { KVPList } from '@arbor-education/design-system.components';
385
+
386
+ function TotalsRowExample() {
387
+ return (
388
+ <KVPList aria-label="Basket totals">
389
+ <KVPList.Row orientation="horizontal">
390
+ <KVPList.Term>Subtotal</KVPList.Term>
391
+ <KVPList.Definition>£40.00</KVPList.Definition>
392
+ </KVPList.Row>
393
+ <KVPList.Row orientation="horizontal">
394
+ <KVPList.Term>Delivery</KVPList.Term>
395
+ <KVPList.Definition>£2.00</KVPList.Definition>
396
+ </KVPList.Row>
397
+ <KVPList.Row orientation="horizontal">
398
+ <KVPList.Term prominence="strong">Total</KVPList.Term>
399
+ <KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
400
+ </KVPList.Row>
401
+ </KVPList>
402
+ );
403
+ }
404
+ export default TotalsRowExample;
405
+ `.trim(),
406
+ },
407
+ },
408
+ },
409
+ render: () => (
410
+ <KVPList aria-label="Basket totals">
411
+ <KVPList.Row orientation="horizontal">
412
+ <KVPList.Term>Subtotal</KVPList.Term>
413
+ <KVPList.Definition>£40.00</KVPList.Definition>
414
+ </KVPList.Row>
415
+ <KVPList.Row orientation="horizontal">
416
+ <KVPList.Term>Delivery</KVPList.Term>
417
+ <KVPList.Definition>£2.00</KVPList.Definition>
418
+ </KVPList.Row>
419
+ <KVPList.Row orientation="horizontal">
420
+ <KVPList.Term prominence="strong">Total</KVPList.Term>
421
+ <KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
422
+ </KVPList.Row>
423
+ </KVPList>
424
+ ),
425
+ },
426
+ '`prominence="strong"` on `KVPList.Term` applies the bold body weight token (`--type-body-bold-weight`) so the label of a totals row balances a strong definition. Unemphasised terms are unchanged.',
427
+ );
428
+
375
429
  export const IsRow: Story = withDescription(
376
430
  {
377
431
  parameters: {
@@ -51,6 +51,58 @@ describe('KVPList', () => {
51
51
  expect(container.querySelector('.ds-kvp-list__description--row')).toBeInTheDocument();
52
52
  });
53
53
 
54
+ test('applies the strong modifier to a term with strong prominence', () => {
55
+ render(
56
+ <KVPList>
57
+ <KVPList.Row orientation="horizontal">
58
+ <KVPList.Term prominence="strong">Total</KVPList.Term>
59
+ <KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
60
+ </KVPList.Row>
61
+ </KVPList>,
62
+ );
63
+
64
+ expect(screen.getByText('Total')).toHaveClass('ds-kvp-list__term', 'ds-kvp-list__term--strong');
65
+ });
66
+
67
+ test('does not forward prominence to the term element', () => {
68
+ render(
69
+ <KVPList>
70
+ <KVPList.Row orientation="horizontal">
71
+ <KVPList.Term prominence="strong">Total</KVPList.Term>
72
+ <KVPList.Definition prominence="strong">£42.00</KVPList.Definition>
73
+ </KVPList.Row>
74
+ </KVPList>,
75
+ );
76
+
77
+ expect(screen.getByText('Total')).not.toHaveAttribute('prominence');
78
+ });
79
+
80
+ test('leaves a term without prominence unchanged', () => {
81
+ render(
82
+ <KVPList>
83
+ <KVPList.Row orientation="horizontal">
84
+ <KVPList.Term className="custom-term">Subtotal</KVPList.Term>
85
+ <KVPList.Definition>£40.00</KVPList.Definition>
86
+ </KVPList.Row>
87
+ </KVPList>,
88
+ );
89
+
90
+ expect(screen.getByText('Subtotal')).toHaveAttribute('class', 'ds-kvp-list__term custom-term');
91
+ });
92
+
93
+ test('leaves a term with neutral prominence unchanged', () => {
94
+ render(
95
+ <KVPList>
96
+ <KVPList.Row orientation="horizontal">
97
+ <KVPList.Term prominence="neutral">Delivery</KVPList.Term>
98
+ <KVPList.Definition>£2.00</KVPList.Definition>
99
+ </KVPList.Row>
100
+ </KVPList>,
101
+ );
102
+
103
+ expect(screen.getByText('Delivery')).toHaveAttribute('class', 'ds-kvp-list__term');
104
+ });
105
+
54
106
  test('does not add group semantics when no accessible label is provided', () => {
55
107
  render(
56
108
  <KVPList>
@@ -11,6 +11,7 @@ export type KVPListRowProps = React.HTMLAttributes<HTMLDListElement> & {
11
11
 
12
12
  export type KVPListTermProps = React.HTMLAttributes<HTMLElement> & {
13
13
  children?: React.ReactNode;
14
+ prominence?: 'neutral' | 'strong';
14
15
  };
15
16
 
16
17
  export type KVPListDefinitionProps = React.HTMLAttributes<HTMLElement> & {
@@ -60,9 +61,17 @@ const KVPListRow = ({
60
61
  const KVPListTerm = ({
61
62
  children,
62
63
  className,
64
+ prominence = 'neutral',
63
65
  ...rest
64
66
  }: KVPListTermProps): React.JSX.Element => (
65
- <dt className={classNames('ds-kvp-list__term', className)} {...rest}>
67
+ <dt
68
+ className={classNames(
69
+ 'ds-kvp-list__term',
70
+ { 'ds-kvp-list__term--strong': prominence === 'strong' },
71
+ className,
72
+ )}
73
+ {...rest}
74
+ >
66
75
  {children}
67
76
  </dt>
68
77
  );
@@ -21,6 +21,10 @@
21
21
  line-height: var(--type-body-line-height);
22
22
  }
23
23
 
24
+ .ds-kvp-list__term--strong {
25
+ font-weight: var(--type-body-bold-weight);
26
+ }
27
+
24
28
  .ds-kvp-list__description--strong {
25
29
  color: var(--kvp-list-color-text-strong);
26
30
  font-family: var(--type-headings-h2-family);
@@ -25,6 +25,7 @@ const DESCRIPTION_INTRO = [
25
25
  'Pass an `onClick` handler to make the row fully interactive — it enters the tab order, gains hover and focus styles, and renders a pair of animated directional icons.',
26
26
  'Pass `href` to turn the value into a native anchor link, supporting any protocol including `mailto:` and `tel:`.',
27
27
  'Pass `tooltipText` to show an explanatory tooltip when a user hovers the row — useful for read-only fields or values that need additional context.',
28
+ 'Pass `isActive` to highlight the row as the one currently being worked on, for example the row a slideover was opened from.',
28
29
  ].join(' ');
29
30
 
30
31
  const PROPS_INTRO = 'The preview below is wired to the **Controls** panel — tweak any prop to see the story update in real time.';
@@ -60,6 +61,9 @@ const DEVELOPER_NOTES = [
60
61
  '| Static | `-1` (not in tab order) | — | None |',
61
62
  '| Clickable | `0` (in tab order) | `ds-row--clickable` | `chevron-right` + `arrow-right` |',
62
63
  '',
64
+ '`isActive` is independent of `onClick`: it adds the `ds-row--active` modifier and `aria-current="true"`, and can be combined with either state.',
65
+ 'The active background is kept on hover so an active clickable row stays distinguishable from its siblings.',
66
+ '',
63
67
  '**Dual-icon swap:** Both icons are always in the DOM when `onClick` is provided. CSS controls visibility —',
64
68
  'at rest `chevron-right` shows; on hover/focus `arrow-right` shows. No JavaScript state is involved.',
65
69
  '',
@@ -109,6 +113,7 @@ const DEVELOPER_NOTES = [
109
113
  '|---|---|',
110
114
  '| `--section-list-row-default-color-background` | Resting background colour |',
111
115
  '| `--section-list-row-hover-hover-bg` | Hover background (clickable rows only) |',
116
+ '| `--section-list-row-active-active-bg` | Background of an active row (`isActive`), including on hover |',
112
117
  '| `--section-list-row-spacing-vertical` | Top/bottom padding |',
113
118
  '| `--section-list-row-spacing-horizontal` | Left/right padding |',
114
119
  '| `--section-list-row-radius` | Border radius |',
@@ -133,6 +138,7 @@ const DEVELOPER_NOTES = [
133
138
  ' note?: ReactNode;',
134
139
  ' placeholder?: string;',
135
140
  ' tooltipText?: string;',
141
+ ' isActive?: boolean;',
136
142
  ' onClick?: MouseEventHandler<HTMLDivElement>;',
137
143
  '};',
138
144
  '```',
@@ -256,6 +262,18 @@ const meta = {
256
262
  defaultValue: { summary: 'undefined' },
257
263
  },
258
264
  },
265
+ isActive: {
266
+ description: [
267
+ 'Marks the row as the currently active or selected row, such as the row a slideover was opened from.',
268
+ 'Adds the `ds-row--active` modifier and `aria-current="true"`, and applies the `--section-list-row-active-active-bg` background, which is kept on hover.',
269
+ 'Combine with `onClick` for an active row that is also clickable.',
270
+ ].join(' '),
271
+ control: 'boolean',
272
+ table: {
273
+ type: { summary: 'boolean' },
274
+ defaultValue: { summary: 'false' },
275
+ },
276
+ },
259
277
  onClick: {
260
278
  description: [
261
279
  'When provided, activates all clickable behaviour: the row enters the tab order (`tabIndex={0}`),',
@@ -757,6 +775,51 @@ export default GuardianRow;
757
775
  'Clickable row with all three right-side elements active: value, note, and the chevron/arrow icon pair. The note takes 20% flex-basis; the value narrows to 50%; the icon is flex-shrunk. The secondary context ("Primary contact") persists alongside the navigation affordance.',
758
776
  );
759
777
 
778
+ export const Active: Story = withDescription(
779
+ {
780
+ parameters: {
781
+ controls: { disable: true },
782
+ docs: {
783
+ source: {
784
+ language: 'tsx',
785
+ code: `
786
+ import { Row, Section } from '@arbor-education/design-system.components';
787
+
788
+ function GuardianSection({ activeGuardianId, openGuardian }) {
789
+ return (
790
+ <Section title="Guardians">
791
+ <Row
792
+ label="Mother"
793
+ value="Sarah Okafor"
794
+ isActive={activeGuardianId === 'guardian-42'}
795
+ onClick={() => openGuardian('guardian-42')}
796
+ />
797
+ <Row
798
+ label="Father"
799
+ value="David Okafor"
800
+ isActive={activeGuardianId === 'guardian-43'}
801
+ onClick={() => openGuardian('guardian-43')}
802
+ />
803
+ <Row label="Emergency contact" value="Grace Mensah" isActive={false} />
804
+ </Section>
805
+ );
806
+ }
807
+ export default GuardianSection;
808
+ `.trim(),
809
+ },
810
+ },
811
+ },
812
+ render: () => (
813
+ <Section title="Guardians">
814
+ <Row label="Mother" value="Sarah Okafor" isActive onClick={fn()} />
815
+ <Row label="Father" value="David Okafor" onClick={fn()} />
816
+ <Row label="Emergency contact" value="Grace Mensah" />
817
+ </Section>
818
+ ),
819
+ },
820
+ 'Set `isActive` to highlight the row currently being worked on — for example the row a slideover was opened from. The first row is active **and** clickable: it keeps the active background on hover, retains its focus ring and keyboard activation, and exposes `aria-current="true"`. The other rows render exactly as they do without the prop.',
821
+ );
822
+
760
823
  export const MixedClickableAndStatic: Story = withDescription(
761
824
  {
762
825
  parameters: {
@@ -106,6 +106,65 @@ describe('Row component', () => {
106
106
  });
107
107
  });
108
108
 
109
+ describe('active behaviour', () => {
110
+ test('applies the active modifier when isActive is true', () => {
111
+ const { container } = render(<Row label="Guardian" value="Sarah Okafor" isActive />);
112
+
113
+ expect(container.querySelector('.ds-row')).toHaveClass('ds-row--active');
114
+ });
115
+
116
+ test('marks an active row as the current item for assistive technology', () => {
117
+ const { container } = render(<Row label="Guardian" value="Sarah Okafor" isActive />);
118
+
119
+ expect(container.querySelector('.ds-row')).toHaveAttribute('aria-current', 'true');
120
+ });
121
+
122
+ test('does not apply the active modifier when isActive is omitted', () => {
123
+ const { container } = render(<Row label="Guardian" value="Sarah Okafor" />);
124
+
125
+ const row = container.querySelector('.ds-row');
126
+ expect(row).not.toHaveClass('ds-row--active');
127
+ expect(row).not.toHaveAttribute('aria-current');
128
+ });
129
+
130
+ test('does not apply the active modifier when isActive is false', () => {
131
+ const { container } = render(<Row label="Guardian" value="Sarah Okafor" isActive={false} />);
132
+
133
+ expect(container.querySelector('.ds-row')).not.toHaveClass('ds-row--active');
134
+ });
135
+
136
+ test('keeps clickable behaviour on an active clickable row', async () => {
137
+ const handleClick = vi.fn();
138
+ const { container } = render(
139
+ <Row label="Guardian" value="Sarah Okafor" isActive onClick={handleClick} />,
140
+ );
141
+
142
+ const row = container.querySelector('.ds-row')!;
143
+ expect(row).toHaveClass('ds-row--active', 'ds-row--clickable');
144
+ expect(row).toHaveAttribute('tabindex', '0');
145
+
146
+ await userEvent.click(screen.getByText('Sarah Okafor'));
147
+
148
+ expect(handleClick).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ test('activates an active clickable row with the keyboard', async () => {
152
+ const handleClick = vi.fn();
153
+ render(<Row label="Guardian" value="Sarah Okafor" isActive onClick={handleClick} />);
154
+
155
+ await userEvent.tab();
156
+ await userEvent.keyboard('{Enter}');
157
+
158
+ expect(handleClick).toHaveBeenCalledTimes(1);
159
+ });
160
+
161
+ test('combines the active modifier with a custom className', () => {
162
+ const { container } = render(<Row label="Guardian" className="custom-row" isActive />);
163
+
164
+ expect(container.querySelector('.ds-row')).toHaveClass('ds-row', 'ds-row--active', 'custom-row');
165
+ });
166
+ });
167
+
109
168
  describe('placeholder behaviour', () => {
110
169
  test('renders placeholder text when no value is provided', () => {
111
170
  render(<Row label="Medical condition" placeholder="Not recorded" />);
@@ -13,6 +13,7 @@ export type RowProps = {
13
13
  note?: ReactNode;
14
14
  placeholder?: string;
15
15
  tooltipText?: string;
16
+ isActive?: boolean;
16
17
  onClick?: MouseEventHandler<HTMLDivElement>;
17
18
  };
18
19
 
@@ -26,6 +27,7 @@ export const Row = (props: RowProps) => {
26
27
  note,
27
28
  placeholder,
28
29
  tooltipText,
30
+ isActive = false,
29
31
  onClick,
30
32
  } = props;
31
33
 
@@ -47,10 +49,12 @@ export const Row = (props: RowProps) => {
47
49
  'ds-row',
48
50
  {
49
51
  'ds-row--clickable': isClickable,
52
+ 'ds-row--active': isActive,
50
53
  },
51
54
  className,
52
55
  )}
53
56
  style={style}
57
+ aria-current={isActive ? 'true' : undefined}
54
58
  onClick={onClick}
55
59
  onKeyDown={(e) => {
56
60
  if (isClickable && [ENTER_KEY, SPACE_KEY].includes(e.key)) {
@@ -11,7 +11,6 @@
11
11
  .ds-row__icon-click {
12
12
  flex-shrink: 0;
13
13
  stroke: var(--section-list-row-default-color-icon-arrow);
14
-
15
14
 
16
15
  &.ds-icon-arrow-right {
17
16
  display: none;
@@ -26,6 +25,11 @@
26
25
  }
27
26
  }
28
27
 
28
+ &--active,
29
+ &--active:hover {
30
+ background-color: var(--section-list-row-active-active-bg);
31
+ }
32
+
29
33
  &:focus {
30
34
  box-shadow: 0 0 0 3px var(--color-brand-300);
31
35
  outline: none;
@@ -34,7 +38,6 @@
34
38
  &:hover {
35
39
  .ds-row__icon-click {
36
40
  stroke: var(--section-list-row-hover-color-icon-arrow);
37
-
38
41
 
39
42
  &.ds-icon-chevron-right {
40
43
  display: none;
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import '@testing-library/jest-dom/vitest';
5
+ import { NumberCellEditor, type NumberCellEditorProps } from './NumberCellEditor.js';
6
+
7
+ const createProps = (overrides: Partial<NumberCellEditorProps> = {}): NumberCellEditorProps => ({
8
+ value: undefined,
9
+ onValueChange: vi.fn(),
10
+ step: 1,
11
+ disableSpinners: true,
12
+ ...overrides,
13
+ });
14
+
15
+ const lastCommittedValue = (onValueChange: ReturnType<typeof vi.fn>) => (
16
+ onValueChange.mock.calls.at(-1)?.[0]
17
+ );
18
+
19
+ describe('NumberCellEditor', () => {
20
+ test('commits typed numbers unchanged when no min or max is supplied', async () => {
21
+ const onValueChange = vi.fn();
22
+ render(<NumberCellEditor {...createProps({ onValueChange })} />);
23
+
24
+ await userEvent.type(screen.getByRole('textbox'), '42');
25
+
26
+ expect(onValueChange).toHaveBeenCalledWith(4);
27
+ expect(onValueChange).toHaveBeenLastCalledWith(42);
28
+ });
29
+
30
+ test('commits the typed number on Enter when no min or max is supplied', async () => {
31
+ const onValueChange = vi.fn();
32
+ render(<NumberCellEditor {...createProps({ onValueChange })} />);
33
+
34
+ await userEvent.type(screen.getByRole('textbox'), '-1234.5{Enter}');
35
+
36
+ expect(lastCommittedValue(onValueChange)).toBe(-1234.5);
37
+ });
38
+
39
+ test('clamps to min and leaves values above min unchanged when only min is supplied', async () => {
40
+ const onValueChange = vi.fn();
41
+ render(<NumberCellEditor {...createProps({ onValueChange, min: 10 })} />);
42
+
43
+ await userEvent.type(screen.getByRole('textbox'), '5');
44
+ expect(lastCommittedValue(onValueChange)).toBe(10);
45
+
46
+ await userEvent.clear(screen.getByRole('textbox'));
47
+ await userEvent.type(screen.getByRole('textbox'), '500');
48
+ expect(lastCommittedValue(onValueChange)).toBe(500);
49
+ });
50
+
51
+ test('clamps to max and leaves values below max unchanged when only max is supplied', async () => {
52
+ const onValueChange = vi.fn();
53
+ render(<NumberCellEditor {...createProps({ onValueChange, max: 100 })} />);
54
+
55
+ await userEvent.type(screen.getByRole('textbox'), '500');
56
+ expect(lastCommittedValue(onValueChange)).toBe(100);
57
+
58
+ await userEvent.clear(screen.getByRole('textbox'));
59
+ await userEvent.type(screen.getByRole('textbox'), '-500');
60
+ expect(lastCommittedValue(onValueChange)).toBe(-500);
61
+ });
62
+
63
+ test('clamps out-of-range values when both min and max are supplied', async () => {
64
+ const onValueChange = vi.fn();
65
+ render(<NumberCellEditor {...createProps({ onValueChange, min: 0, max: 100 })} />);
66
+
67
+ await userEvent.type(screen.getByRole('textbox'), '-5');
68
+ expect(lastCommittedValue(onValueChange)).toBe(0);
69
+
70
+ await userEvent.clear(screen.getByRole('textbox'));
71
+ await userEvent.type(screen.getByRole('textbox'), '250{Enter}');
72
+ expect(lastCommittedValue(onValueChange)).toBe(100);
73
+ });
74
+ });
@@ -7,8 +7,8 @@ import {
7
7
  export type NumberCellEditorProps = {
8
8
  value: number | null | undefined;
9
9
  onValueChange: (value: number | null) => void;
10
- min: number;
11
- max: number;
10
+ min?: number;
11
+ max?: number;
12
12
  step: number;
13
13
  disableSpinners: boolean;
14
14
  };
@@ -21,8 +21,8 @@ export const NumberCellEditor = (props: NumberCellEditorProps) => {
21
21
  const {
22
22
  value,
23
23
  onValueChange,
24
- min,
25
- max,
24
+ min = -Infinity,
25
+ max = Infinity,
26
26
  step,
27
27
  disableSpinners,
28
28
  } = props;
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { renderHook } from '@testing-library/react';
3
+ import { useIsControlledOnceSet } from './useIsControlledOnceSet.js';
4
+
5
+ describe('useIsControlledOnceSet', () => {
6
+ test('is uncontrolled while no value has been received', () => {
7
+ const { result, rerender } = renderHook(
8
+ ({ value }) => useIsControlledOnceSet(value),
9
+ { initialProps: { value: undefined as Date | undefined } },
10
+ );
11
+
12
+ rerender({ value: undefined });
13
+
14
+ expect(result.current).toBe(false);
15
+ });
16
+
17
+ test('is controlled from the first render with a value', () => {
18
+ const { result } = renderHook(() => useIsControlledOnceSet(new Date(2026, 3, 14)));
19
+
20
+ expect(result.current).toBe(true);
21
+ });
22
+
23
+ test('becomes controlled when a value arrives after an empty mount', () => {
24
+ const { result, rerender } = renderHook(
25
+ ({ value }) => useIsControlledOnceSet(value),
26
+ { initialProps: { value: undefined as Date | undefined } },
27
+ );
28
+
29
+ rerender({ value: new Date(2026, 3, 14) });
30
+
31
+ expect(result.current).toBe(true);
32
+ });
33
+
34
+ test('stays controlled when the value is cleared', () => {
35
+ const { result, rerender } = renderHook(
36
+ ({ value }) => useIsControlledOnceSet(value),
37
+ { initialProps: { value: new Date(2026, 3, 14) as Date | undefined } },
38
+ );
39
+
40
+ rerender({ value: undefined });
41
+
42
+ expect(result.current).toBe(true);
43
+ });
44
+ });
@@ -0,0 +1,13 @@
1
+ import { useRef } from 'react';
2
+
3
+ /**
4
+ * Treats a component as controlled from the first render that receives a `value`, so a later
5
+ * `undefined` means "controlled and empty" rather than reverting to uncontrolled.
6
+ */
7
+ export const useIsControlledOnceSet = <T>(value: T | undefined) => {
8
+ const hasReceivedValueRef = useRef(value !== undefined);
9
+ if (value !== undefined) {
10
+ hasReceivedValueRef.current = true;
11
+ }
12
+ return hasReceivedValueRef.current;
13
+ };