@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
@@ -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 {
@@ -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,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
+ };