@arbor-education/design-system.components 4.9.2 → 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.
- package/CHANGELOG.md +20 -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.stories.d.ts.map +1 -1
- package/dist/components/formField/FormField.stories.js +3 -4
- package/dist/components/formField/FormField.stories.js.map +1 -1
- package/dist/components/formField/FormField.test.js +1 -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 +3 -4
- package/src/components/formField/FormField.test.tsx +1 -1
- 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
|
@@ -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
|
+
};
|