@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.
- package/CHANGELOG.md +26 -0
- package/dist/components/datePicker/DatePicker.d.ts.map +1 -1
- package/dist/components/datePicker/DatePicker.js +2 -1
- package/dist/components/datePicker/DatePicker.js.map +1 -1
- package/dist/components/datePicker/DatePicker.test.js +32 -0
- package/dist/components/datePicker/DatePicker.test.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.d.ts +2 -1
- package/dist/components/datePicker/dateInputUtils.d.ts.map +1 -1
- package/dist/components/datePicker/dateInputUtils.js +20 -0
- package/dist/components/datePicker/dateInputUtils.js.map +1 -1
- package/dist/components/datePicker/dateInputUtils.test.d.ts +2 -0
- package/dist/components/datePicker/dateInputUtils.test.d.ts.map +1 -0
- package/dist/components/datePicker/dateInputUtils.test.js +28 -0
- package/dist/components/datePicker/dateInputUtils.test.js.map +1 -0
- package/dist/components/dateTimePicker/DateTimePicker.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.js +34 -10
- package/dist/components/dateTimePicker/DateTimePicker.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.d.ts.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.stories.js +5 -0
- package/dist/components/dateTimePicker/DateTimePicker.stories.js.map +1 -1
- package/dist/components/dateTimePicker/DateTimePicker.test.js +136 -0
- package/dist/components/dateTimePicker/DateTimePicker.test.js.map +1 -1
- package/dist/components/formField/FormField.d.ts +1 -0
- package/dist/components/formField/FormField.d.ts.map +1 -1
- package/dist/components/formField/FormField.js +2 -2
- package/dist/components/formField/FormField.js.map +1 -1
- package/dist/components/formField/FormField.stories.d.ts +9 -0
- package/dist/components/formField/FormField.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +28 -16
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +23 -1
- package/dist/components/formField/FormField.test.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts +4 -1
- package/dist/components/formField/inputs/textArea/TextArea.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.js +33 -14
- package/dist/components/formField/inputs/textArea/TextArea.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts +6 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.stories.js +33 -13
- package/dist/components/formField/inputs/textArea/TextArea.stories.js.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.d.ts.map +1 -1
- package/dist/components/formField/inputs/textArea/TextArea.test.js +76 -2
- package/dist/components/formField/inputs/textArea/TextArea.test.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.js +24 -8
- package/dist/components/formField/inputs/time/TimeInput.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts +0 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.d.ts.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.stories.js +38 -67
- package/dist/components/formField/inputs/time/TimeInput.stories.js.map +1 -1
- package/dist/components/formField/inputs/time/TimeInput.test.js +51 -6
- package/dist/components/formField/inputs/time/TimeInput.test.js.map +1 -1
- package/dist/components/kvpList/KVPList.d.ts +2 -1
- package/dist/components/kvpList/KVPList.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.js +1 -1
- package/dist/components/kvpList/KVPList.js.map +1 -1
- package/dist/components/kvpList/KVPList.stories.d.ts +2 -1
- package/dist/components/kvpList/KVPList.stories.d.ts.map +1 -1
- package/dist/components/kvpList/KVPList.stories.js +35 -0
- package/dist/components/kvpList/KVPList.stories.js.map +1 -1
- package/dist/components/kvpList/KVPList.test.js +16 -0
- package/dist/components/kvpList/KVPList.test.js.map +1 -1
- package/dist/components/row/Row.d.ts +1 -0
- package/dist/components/row/Row.d.ts.map +1 -1
- package/dist/components/row/Row.js +3 -2
- package/dist/components/row/Row.js.map +1 -1
- package/dist/components/row/Row.stories.d.ts +13 -0
- package/dist/components/row/Row.stories.d.ts.map +1 -1
- package/dist/components/row/Row.stories.js +53 -0
- package/dist/components/row/Row.stories.js.map +1 -1
- package/dist/components/row/Row.test.js +40 -0
- package/dist/components/row/Row.test.js.map +1 -1
- package/dist/index.css +10 -5
- package/dist/index.css.map +1 -1
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts +6 -0
- package/dist/utils/hooks/useIsControlledOnceSet.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js +13 -0
- package/dist/utils/hooks/useIsControlledOnceSet.js.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts +2 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.d.ts.map +1 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js +25 -0
- package/dist/utils/hooks/useIsControlledOnceSet.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/banner/banner.scss +4 -0
- package/src/components/datePicker/DatePicker.test.tsx +48 -0
- package/src/components/datePicker/DatePicker.tsx +2 -1
- package/src/components/datePicker/dateInputUtils.test.ts +29 -0
- package/src/components/datePicker/dateInputUtils.ts +24 -0
- package/src/components/dateTimePicker/DateTimePicker.stories.tsx +5 -0
- package/src/components/dateTimePicker/DateTimePicker.test.tsx +186 -0
- package/src/components/dateTimePicker/DateTimePicker.tsx +38 -8
- package/src/components/formField/FormField.stories.tsx +32 -18
- package/src/components/formField/FormField.test.tsx +28 -1
- package/src/components/formField/FormField.tsx +3 -2
- package/src/components/formField/inputs/textArea/TextArea.stories.tsx +61 -13
- package/src/components/formField/inputs/textArea/TextArea.test.tsx +94 -2
- package/src/components/formField/inputs/textArea/TextArea.tsx +39 -13
- package/src/components/formField/inputs/time/TimeInput.stories.tsx +38 -87
- package/src/components/formField/inputs/time/TimeInput.test.tsx +75 -6
- package/src/components/formField/inputs/time/TimeInput.tsx +29 -10
- package/src/components/formField/inputs/time/timeInput.scss +0 -6
- package/src/components/kvpList/KVPList.stories.tsx +54 -0
- package/src/components/kvpList/KVPList.test.tsx +52 -0
- package/src/components/kvpList/KVPList.tsx +10 -1
- package/src/components/kvpList/kvpList.scss +4 -0
- package/src/components/row/Row.stories.tsx +63 -0
- package/src/components/row/Row.test.tsx +59 -0
- package/src/components/row/Row.tsx +4 -0
- package/src/components/row/row.scss +5 -2
- package/src/utils/hooks/useIsControlledOnceSet.test.ts +44 -0
- 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
|
|
22
|
+
test('renders a text input so no browser picker is drawn', () => {
|
|
23
23
|
render(<TimeInput aria-label="Start time" />);
|
|
24
|
-
const input = screen.getByLabelText('Start time');
|
|
25
24
|
|
|
26
|
-
expect(
|
|
27
|
-
|
|
25
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('type', 'text');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('shows HH:mm as the placeholder by default', () => {
|
|
29
|
+
render(<TimeInput aria-label="Start time" />);
|
|
30
|
+
|
|
31
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm');
|
|
28
32
|
});
|
|
29
33
|
|
|
30
|
-
test('
|
|
34
|
+
test('shows HH:mm:ss as the placeholder when granularity is second', () => {
|
|
31
35
|
render(<TimeInput aria-label="Start time" granularity="second" />);
|
|
32
|
-
|
|
36
|
+
|
|
37
|
+
expect(screen.getByLabelText('Start time')).toHaveAttribute('placeholder', 'HH:mm:ss');
|
|
33
38
|
});
|
|
34
39
|
|
|
35
40
|
test('native mode emits the string value when changed', () => {
|
|
@@ -43,6 +48,70 @@ describe('TimeInput', () => {
|
|
|
43
48
|
expect(onValueChange).toHaveBeenCalledWith('14:30');
|
|
44
49
|
});
|
|
45
50
|
|
|
51
|
+
test('blocks keystrokes other than digits and colons', async () => {
|
|
52
|
+
const user = userEvent.setup();
|
|
53
|
+
const onValueChange = vi.fn();
|
|
54
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
55
|
+
|
|
56
|
+
await user.type(screen.getByLabelText('Start time'), '9.7x:a');
|
|
57
|
+
|
|
58
|
+
expect(onValueChange).toHaveBeenLastCalledWith('97:');
|
|
59
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('97:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('strips non-time characters from pasted text', async () => {
|
|
63
|
+
const user = userEvent.setup();
|
|
64
|
+
const onValueChange = vi.fn();
|
|
65
|
+
render(<TimeInput aria-label="Start time" onValueChange={onValueChange} />);
|
|
66
|
+
const input = screen.getByLabelText('Start time');
|
|
67
|
+
|
|
68
|
+
await user.click(input);
|
|
69
|
+
await user.paste('abc14:30');
|
|
70
|
+
|
|
71
|
+
expect(onValueChange).toHaveBeenLastCalledWith('14:30');
|
|
72
|
+
expect(input).toHaveValue('14:30');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a typed colon is accepted', async () => {
|
|
76
|
+
const user = userEvent.setup();
|
|
77
|
+
render(<TimeInput aria-label="Start time" />);
|
|
78
|
+
|
|
79
|
+
await user.type(screen.getByLabelText('Start time'), '14:30');
|
|
80
|
+
|
|
81
|
+
expect(screen.getByLabelText('Start time')).toHaveValue('14:30');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('does not set aria-invalid itself, so validation stays with the consumer', async () => {
|
|
85
|
+
const user = userEvent.setup();
|
|
86
|
+
render(<TimeInput aria-label="Start time" />);
|
|
87
|
+
const input = screen.getByLabelText('Start time');
|
|
88
|
+
|
|
89
|
+
await user.type(input, 'abc');
|
|
90
|
+
|
|
91
|
+
expect(input).not.toHaveAttribute('aria-invalid');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('applies error styling from hasError and reflects aria-invalid passed by the consumer', () => {
|
|
95
|
+
render(<TimeInput aria-label="Start time" hasError aria-invalid />);
|
|
96
|
+
const input = screen.getByLabelText('Start time');
|
|
97
|
+
|
|
98
|
+
expect(input).toHaveClass('ds-input--error');
|
|
99
|
+
expect(input).toHaveAttribute('aria-invalid', 'true');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('submits the typed text under the field name', async () => {
|
|
103
|
+
const user = userEvent.setup();
|
|
104
|
+
render(
|
|
105
|
+
<form aria-label="Schedule">
|
|
106
|
+
<TimeInput aria-label="Start time" name="start" />
|
|
107
|
+
</form>,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await user.type(screen.getByLabelText('Start time'), '9:05');
|
|
111
|
+
|
|
112
|
+
expect(new FormData(screen.getByRole<HTMLFormElement>('form')).get('start')).toBe('9:05');
|
|
113
|
+
});
|
|
114
|
+
|
|
46
115
|
test('clicking the clock icon focuses the input and selects the hour segment when supported', async () => {
|
|
47
116
|
const user = userEvent.setup();
|
|
48
117
|
const setSelectionRange = vi.spyOn(HTMLInputElement.prototype, 'setSelectionRange').mockImplementation(() => {});
|
|
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
|
|
2
2
|
import { Combobox } from 'Components/combobox/Combobox';
|
|
3
3
|
import type { ComboboxOption, ComboboxSearchType } from 'Components/combobox/types';
|
|
4
4
|
import { Icon } from 'Components/icon/Icon';
|
|
5
|
-
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes } from 'react';
|
|
5
|
+
import { forwardRef, useCallback, useMemo, useRef, useState, type ChangeEvent, type InputHTMLAttributes, type KeyboardEvent } from 'react';
|
|
6
6
|
|
|
7
7
|
/** Expected string `HH:MM` or `HH:MM:SS`; format-shaped versus strict time validation. */
|
|
8
8
|
export type TimeValue = `${string}:${string}` | `${string}:${string}:${string}`;
|
|
@@ -22,6 +22,14 @@ export type TimeInputProps = {
|
|
|
22
22
|
|
|
23
23
|
const clockIcon = <Icon name="clock-3" size={16} />;
|
|
24
24
|
|
|
25
|
+
const TIME_CHARACTER_PATTERN = /^[0-9:]$/;
|
|
26
|
+
const NON_TIME_CHARACTERS_PATTERN = /[^0-9:]/g;
|
|
27
|
+
|
|
28
|
+
const NATIVE_PLACEHOLDER_BY_GRANULARITY: Record<TimeGranularity, string> = {
|
|
29
|
+
minute: 'HH:mm',
|
|
30
|
+
second: 'HH:mm:ss',
|
|
31
|
+
};
|
|
32
|
+
|
|
25
33
|
/** The forwarded ref is attached in native mode but when `options` is provided, no input ref is exposed. */
|
|
26
34
|
export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, ref) => {
|
|
27
35
|
const {
|
|
@@ -32,6 +40,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
32
40
|
searchType = 'prefix',
|
|
33
41
|
highlightStringMatches = false,
|
|
34
42
|
onChange,
|
|
43
|
+
onKeyDown,
|
|
35
44
|
className,
|
|
36
45
|
disabled = false,
|
|
37
46
|
id,
|
|
@@ -76,8 +85,22 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
76
85
|
} as ChangeEvent<HTMLInputElement>);
|
|
77
86
|
}, [isControlled, onChange, onValueChange]);
|
|
78
87
|
|
|
88
|
+
const handleNativeKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
|
89
|
+
const isShortcut = event.ctrlKey || event.metaKey || event.altKey;
|
|
90
|
+
const isPrintable = event.key.length === 1;
|
|
91
|
+
if (isPrintable && !isShortcut && !TIME_CHARACTER_PATTERN.test(event.key)) {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
}
|
|
94
|
+
onKeyDown?.(event);
|
|
95
|
+
}, [onKeyDown]);
|
|
96
|
+
|
|
79
97
|
const handleNativeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
80
|
-
|
|
98
|
+
const { value } = event.currentTarget;
|
|
99
|
+
const timeCharacters = value.replace(NON_TIME_CHARACTERS_PATTERN, '');
|
|
100
|
+
if (timeCharacters !== value) {
|
|
101
|
+
event.currentTarget.value = timeCharacters;
|
|
102
|
+
}
|
|
103
|
+
updateValue(timeCharacters, event);
|
|
81
104
|
}, [updateValue]);
|
|
82
105
|
|
|
83
106
|
const focusAndSelectHours = useCallback(() => {
|
|
@@ -87,11 +110,7 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
87
110
|
}
|
|
88
111
|
|
|
89
112
|
input.focus();
|
|
90
|
-
|
|
91
|
-
try {
|
|
92
|
-
input.setSelectionRange(0, 2);
|
|
93
|
-
}
|
|
94
|
-
catch { /* Some browsers do not expose selection APIs on time inputs. */ }
|
|
113
|
+
input.setSelectionRange(0, 2);
|
|
95
114
|
}, []);
|
|
96
115
|
|
|
97
116
|
const timeOptions = useMemo<ComboboxOption[]>(
|
|
@@ -139,18 +158,18 @@ export const TimeInput = forwardRef<HTMLInputElement, TimeInputProps>((props, re
|
|
|
139
158
|
ref={setRefs}
|
|
140
159
|
id={id}
|
|
141
160
|
name={name}
|
|
142
|
-
type="
|
|
161
|
+
type="text"
|
|
143
162
|
value={currentValue}
|
|
144
|
-
step={granularity === 'second' ? 1 : 60}
|
|
145
163
|
disabled={disabled}
|
|
146
164
|
aria-describedby={ariaDescribedBy}
|
|
147
165
|
aria-invalid={ariaInvalid}
|
|
148
166
|
aria-label={ariaLabel}
|
|
149
|
-
placeholder={placeholder}
|
|
167
|
+
placeholder={placeholder ?? NATIVE_PLACEHOLDER_BY_GRANULARITY[granularity]}
|
|
150
168
|
className={classNames('ds-input', 'ds-input--M', 'ds-time-input__input', {
|
|
151
169
|
'ds-input--error': hasError,
|
|
152
170
|
})}
|
|
153
171
|
onChange={handleNativeChange}
|
|
172
|
+
onKeyDown={handleNativeKeyDown}
|
|
154
173
|
/>
|
|
155
174
|
<button
|
|
156
175
|
type="button"
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
.ds-time-input__input {
|
|
7
|
-
appearance: none;
|
|
8
7
|
padding-right: calc(var(--form-field-text-medium-height) + var(--spacing-small));
|
|
9
|
-
|
|
10
|
-
&::-webkit-calendar-picker-indicator {
|
|
11
|
-
display: none;
|
|
12
|
-
appearance: none;
|
|
13
|
-
}
|
|
14
8
|
}
|
|
15
9
|
|
|
16
10
|
.ds-time-input__icon-button {
|
|
@@ -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
|
|
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
|
+
};
|