@arbor-education/design-system.components 4.4.0 → 4.6.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 (58) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/components/avatar/Avatar.js +2 -2
  3. package/dist/components/avatar/Avatar.js.map +1 -1
  4. package/dist/components/badge/Badge.stories.d.ts +0 -1
  5. package/dist/components/badge/Badge.stories.d.ts.map +1 -1
  6. package/dist/components/badge/Badge.stories.js +0 -69
  7. package/dist/components/badge/Badge.stories.js.map +1 -1
  8. package/dist/components/datePicker/DatePicker.stories.d.ts +5 -0
  9. package/dist/components/datePicker/DatePicker.stories.d.ts.map +1 -1
  10. package/dist/components/datePicker/DatePicker.stories.js +211 -2
  11. package/dist/components/datePicker/DatePicker.stories.js.map +1 -1
  12. package/dist/components/datePicker/DatePickerPopover.d.ts +11 -0
  13. package/dist/components/datePicker/DatePickerPopover.d.ts.map +1 -0
  14. package/dist/components/datePicker/DatePickerPopover.js +33 -0
  15. package/dist/components/datePicker/DatePickerPopover.js.map +1 -0
  16. package/dist/components/datePicker/DatePickerPopover.test.d.ts +2 -0
  17. package/dist/components/datePicker/DatePickerPopover.test.d.ts.map +1 -0
  18. package/dist/components/datePicker/DatePickerPopover.test.js +101 -0
  19. package/dist/components/datePicker/DatePickerPopover.test.js.map +1 -0
  20. package/dist/components/dot/Dot.d.ts.map +1 -1
  21. package/dist/components/dot/Dot.js +1 -1
  22. package/dist/components/dot/Dot.js.map +1 -1
  23. package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts +2 -2
  24. package/dist/components/formField/inputs/checkbox/CheckboxInput.d.ts.map +1 -1
  25. package/dist/components/formField/inputs/checkbox/CheckboxInput.js +1 -1
  26. package/dist/components/formField/inputs/checkbox/CheckboxInput.js.map +1 -1
  27. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts +2 -0
  28. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.d.ts.map +1 -1
  29. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js +100 -0
  30. package/dist/components/formField/inputs/checkbox/CheckboxInput.stories.js.map +1 -1
  31. package/dist/components/formField/inputs/checkbox/CheckboxInput.test.js +17 -1
  32. package/dist/components/formField/inputs/checkbox/CheckboxInput.test.js.map +1 -1
  33. package/dist/components/searchBar/SearchBar.d.ts.map +1 -1
  34. package/dist/components/searchBar/SearchBar.js +1 -1
  35. package/dist/components/searchBar/SearchBar.js.map +1 -1
  36. package/dist/components/searchBar/SearchBar.test.js +3 -3
  37. package/dist/components/searchBar/SearchBar.test.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/utils/popupParent/PopupParentContext.stories.d.ts.map +1 -1
  43. package/dist/utils/popupParent/PopupParentContext.stories.js +1 -1
  44. package/dist/utils/popupParent/PopupParentContext.stories.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/components/avatar/Avatar.tsx +2 -2
  47. package/src/components/badge/Badge.stories.tsx +0 -103
  48. package/src/components/datePicker/DatePicker.stories.tsx +293 -0
  49. package/src/components/datePicker/DatePickerPopover.test.tsx +144 -0
  50. package/src/components/datePicker/DatePickerPopover.tsx +72 -0
  51. package/src/components/dot/Dot.tsx +1 -0
  52. package/src/components/formField/inputs/checkbox/CheckboxInput.stories.tsx +178 -1
  53. package/src/components/formField/inputs/checkbox/CheckboxInput.test.tsx +58 -0
  54. package/src/components/formField/inputs/checkbox/CheckboxInput.tsx +3 -3
  55. package/src/components/searchBar/SearchBar.test.tsx +3 -3
  56. package/src/components/searchBar/SearchBar.tsx +1 -0
  57. package/src/index.ts +1 -0
  58. package/src/utils/popupParent/PopupParentContext.stories.tsx +1 -0
@@ -0,0 +1,72 @@
1
+ import { Popover } from 'radix-ui';
2
+ import { useContext, useState, type ReactElement } from 'react';
3
+ import { PopupParentContext } from 'Utils/popupParent/PopupParentContext';
4
+ import { formatTimeInputValue, mergeDateAndTime } from './dateInputUtils.js';
5
+ import { DatePickerCalendar } from './DatePickerCalendar.js';
6
+ import { DatePickerCalendarHeader } from './DatePickerCalendarHeader.js';
7
+
8
+ const TIME_PRESERVATION_GRANULARITY = 'minute' as const;
9
+
10
+ export type DatePickerPopoverProps = {
11
+ trigger: ReactElement;
12
+ value?: Date;
13
+ onChange?: (newDate: Date) => void;
14
+ };
15
+
16
+ export const DatePickerPopover = (props: DatePickerPopoverProps) => {
17
+ const { trigger, value, onChange } = props;
18
+
19
+ const [isOpen, setIsOpen] = useState(false);
20
+ const [month, setMonth] = useState(value ?? new Date());
21
+ const popupParentRef = useContext(PopupParentContext);
22
+
23
+ const handleOpenChange = (open: boolean) => {
24
+ if (open) {
25
+ setMonth(value ?? new Date());
26
+ }
27
+ setIsOpen(open);
28
+ };
29
+
30
+ const selectDay = (day: Date) => {
31
+ const timeSource = value ?? day;
32
+ onChange?.(
33
+ mergeDateAndTime(
34
+ day,
35
+ formatTimeInputValue(timeSource, TIME_PRESERVATION_GRANULARITY),
36
+ TIME_PRESERVATION_GRANULARITY,
37
+ ),
38
+ );
39
+ setIsOpen(false);
40
+ };
41
+
42
+ const onDayPickerSelect = (day?: Date) => {
43
+ const selectedDay = day ?? value;
44
+ if (selectedDay) {
45
+ selectDay(selectedDay);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
51
+ <Popover.Trigger asChild>{trigger}</Popover.Trigger>
52
+ <Popover.Portal container={popupParentRef.current}>
53
+ <Popover.Content align="end" sideOffset={5} onOpenAutoFocus={e => e.preventDefault()}>
54
+ <div className="ds-date-picker__popup">
55
+ <DatePickerCalendarHeader month={month} onMonthChange={setMonth} />
56
+ <DatePickerCalendar
57
+ month={month}
58
+ setMonth={setMonth}
59
+ selectedDate={value}
60
+ onDayPickerSelect={onDayPickerSelect}
61
+ handleTodayClick={() => selectDay(new Date())}
62
+ />
63
+ </div>
64
+ </Popover.Content>
65
+ </Popover.Portal>
66
+ </Popover.Root>
67
+ );
68
+ };
69
+
70
+ export namespace DatePickerPopover {
71
+ export type Props = DatePickerPopoverProps;
72
+ }
@@ -12,6 +12,7 @@ export const Dot = ({ colour, label }: DotProps): React.JSX.Element => {
12
12
  <span
13
13
  aria-hidden={label ? undefined : 'true'}
14
14
  aria-label={label}
15
+ role="img"
15
16
  className={classNames('ds-dot', `ds-dot--${colour}`)}
16
17
  />
17
18
  );
@@ -8,7 +8,7 @@ import {
8
8
  Subtitle,
9
9
  Title,
10
10
  } from '@storybook/addon-docs/blocks';
11
- import { useState } from 'react';
11
+ import { useState, type ReactNode } from 'react';
12
12
  import { fn } from 'storybook/test';
13
13
  import { CheckboxInput } from './CheckboxInput.js';
14
14
  import { CheckboxGroup } from './CheckboxGroup.js';
@@ -1036,3 +1036,180 @@ export default LockedReportOptionsExample;
1036
1036
  '`disabled` prop inside `options` if you need partial disabling.',
1037
1037
  ].join(' '),
1038
1038
  );
1039
+
1040
+ // ---------------------------------------------------------------------------
1041
+ // Rich labels
1042
+ // Each row shows the plain-text label (what the adaptor shipped before) beside
1043
+ // the rich label, so the before/after difference is visible in one story.
1044
+ // ---------------------------------------------------------------------------
1045
+
1046
+ const EMAIL_ICON_SRC = `data:image/svg+xml;utf8,${encodeURIComponent(
1047
+ '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><rect x="1" y="3" width="12" height="8" rx="1" fill="none" stroke="#1b4f8a" stroke-width="1.5"/><path d="M1.5 3.5 7 8l5.5-4.5" fill="none" stroke="#1b4f8a" stroke-width="1.5"/></svg>',
1048
+ )}`;
1049
+
1050
+ const RICH_LABEL_EXAMPLES: { type: string; plain: string; rich: ReactNode }[] = [
1051
+ {
1052
+ type: 'Bold text',
1053
+ plain: 'Smith, Jane',
1054
+ rich: (
1055
+ <>
1056
+ <b>Smith</b>
1057
+ , Jane
1058
+ </>
1059
+ ),
1060
+ },
1061
+ {
1062
+ type: 'Image',
1063
+ plain: 'Email Send to email',
1064
+ rich: (
1065
+ <>
1066
+ <img src={EMAIL_ICON_SRC} alt="Email" style={{ verticalAlign: 'middle', marginRight: 'var(--spacing-xsmall)' }} />
1067
+ Send to email
1068
+ </>
1069
+ ),
1070
+ },
1071
+ {
1072
+ type: 'Coloured text (warning)',
1073
+ plain: 'No email address',
1074
+ rich: <span style={{ color: 'var(--color-semantic-destructive-700)' }}>No email address</span>,
1075
+ },
1076
+ {
1077
+ type: 'Mixed formatting',
1078
+ plain: 'Maths: 0 students below target',
1079
+ rich: (
1080
+ <>
1081
+ Maths:
1082
+ {' '}
1083
+ <u><span style={{ color: 'var(--color-semantic-destructive-700)', fontWeight: 'bold' }}>0</span></u>
1084
+ {' '}
1085
+ students below target
1086
+ </>
1087
+ ),
1088
+ },
1089
+ ];
1090
+
1091
+ const RichLabelComparisonTemplate = () => {
1092
+ const [checked, setChecked] = useState<Record<string, boolean>>({});
1093
+
1094
+ return (
1095
+ <div style={{ maxWidth: '36rem', padding: 'var(--spacing-xlarge)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-large)' }}>
1096
+ {RICH_LABEL_EXAMPLES.map(example => (
1097
+ <div key={example.type} style={{ display: 'grid', gridTemplateColumns: '10rem 1fr 1fr', gap: 'var(--spacing-medium)', alignItems: 'center' }}>
1098
+ <span style={{ color: 'var(--color-grey-600)', fontSize: 'var(--font-size-2-13)' }}>{example.type}</span>
1099
+ <CheckboxInput
1100
+ id={`plain-${example.type}`}
1101
+ checked={!!checked[`plain-${example.type}`]}
1102
+ onChange={e => setChecked(prev => ({ ...prev, [`plain-${example.type}`]: e.target.checked }))}
1103
+ label={example.plain}
1104
+ />
1105
+ <CheckboxInput
1106
+ id={`rich-${example.type}`}
1107
+ checked={!!checked[`rich-${example.type}`]}
1108
+ onChange={e => setChecked(prev => ({ ...prev, [`rich-${example.type}`]: e.target.checked }))}
1109
+ label={example.rich}
1110
+ />
1111
+ </div>
1112
+ ))}
1113
+ </div>
1114
+ );
1115
+ };
1116
+
1117
+ export const RichLabelComparison: Story = withDescription(
1118
+ {
1119
+ render: () => <RichLabelComparisonTemplate />,
1120
+ parameters: {
1121
+ docs: {
1122
+ source: {
1123
+ language: 'tsx',
1124
+ code: `
1125
+ import { CheckboxInput } from '@arbor-education/design-system.components';
1126
+
1127
+ // label accepts any ReactNode. Keep formatting to the approved set
1128
+ // (bold, coloured text, icon) and keep the text itself meaningful on its own,
1129
+ // because it becomes the checkbox's accessible name.
1130
+ <CheckboxInput
1131
+ id="student-smith"
1132
+ checked={checked}
1133
+ onChange={(e) => setChecked(e.target.checked)}
1134
+ label={<><b>Smith</b>, Jane</>}
1135
+ />
1136
+ `.trim(),
1137
+ },
1138
+ },
1139
+ },
1140
+ },
1141
+ [
1142
+ 'Each row compares a plain-text label (left) with the rich label (right). The rich label keeps the',
1143
+ 'formatting that legacy SIS labels carry: bold names, icons, coloured warnings and highlighted counts.',
1144
+ 'The whole label remains the checkbox accessible name, so the text must make sense without the styling.',
1145
+ ].join(' '),
1146
+ );
1147
+
1148
+ const RICH_GUARDIAN_OPTIONS = [
1149
+ {
1150
+ id: 'rich-guardian-1',
1151
+ label: (
1152
+ <>
1153
+ <b>Smith</b>
1154
+ , Jane (mother)
1155
+ </>
1156
+ ),
1157
+ checked: true,
1158
+ onChange: fn(),
1159
+ },
1160
+ {
1161
+ id: 'rich-guardian-2',
1162
+ label: (
1163
+ <>
1164
+ <img src={EMAIL_ICON_SRC} alt="Email" style={{ verticalAlign: 'middle', marginRight: 'var(--spacing-xsmall)' }} />
1165
+ Smith, Tom (father)
1166
+ </>
1167
+ ),
1168
+ checked: false,
1169
+ onChange: fn(),
1170
+ },
1171
+ {
1172
+ id: 'rich-guardian-3',
1173
+ label: (
1174
+ <>
1175
+ <span style={{ color: 'var(--color-semantic-destructive-700)' }}>No email address</span>
1176
+ {' '}
1177
+ — Smith, Ann
1178
+ </>
1179
+ ),
1180
+ checked: false,
1181
+ onChange: fn(),
1182
+ },
1183
+ ];
1184
+
1185
+ export const RichLabelInCheckboxGroup: Story = withDescription(
1186
+ {
1187
+ render: () => (
1188
+ <div style={{ maxWidth: '28rem', padding: 'var(--spacing-xlarge)' }}>
1189
+ <CheckboxGroup
1190
+ legend="Absence alert recipients"
1191
+ options={RICH_GUARDIAN_OPTIONS}
1192
+ />
1193
+ </div>
1194
+ ),
1195
+ parameters: {
1196
+ docs: {
1197
+ source: {
1198
+ language: 'tsx',
1199
+ code: `
1200
+ import { CheckboxGroup } from '@arbor-education/design-system.components';
1201
+
1202
+ <CheckboxGroup
1203
+ legend="Absence alert recipients"
1204
+ options={[
1205
+ { id: 'guardian-1', label: <><b>Smith</b>, Jane (mother)</>, checked: true, onChange },
1206
+ { id: 'guardian-2', label: <><img src={emailIcon} alt="Email" />Smith, Tom (father)</>, checked: false, onChange },
1207
+ ]}
1208
+ />
1209
+ `.trim(),
1210
+ },
1211
+ },
1212
+ },
1213
+ },
1214
+ 'Rich labels in a CheckboxGroup. Each option keeps its own formatting; the group legend still labels the set.',
1215
+ );
@@ -32,4 +32,62 @@ describe('CheckboxInput component', () => {
32
32
  expect(checkboxElement).toBeDisabled();
33
33
  expect(checkboxElement.matches(':disabled')).toBe(true);
34
34
  });
35
+
36
+ test('CheckboxInput renders a rich label with its formatting', () => {
37
+ render(
38
+ <CheckboxInput
39
+ onChange={() => {}}
40
+ label={(
41
+ <>
42
+ <b>Smith</b>
43
+ , Jane
44
+ </>
45
+ )}
46
+ />,
47
+ );
48
+
49
+ expect(screen.getByText('Smith').tagName).toBe('B');
50
+ });
51
+
52
+ test('CheckboxInput keeps the rich label as its accessible name', () => {
53
+ render(
54
+ <CheckboxInput
55
+ onChange={() => {}}
56
+ label={(
57
+ <>
58
+ <span style={{ color: 'red' }}>No email address</span>
59
+ </>
60
+ )}
61
+ />,
62
+ );
63
+
64
+ expect(
65
+ screen.getByRole('checkbox', { name: 'No email address' }),
66
+ ).toBeInTheDocument();
67
+ });
68
+
69
+ test('CheckboxInput renders a numeric zero label inside the label text', () => {
70
+ const { container } = render(<CheckboxInput label={0} onChange={() => {}} />);
71
+
72
+ expect(container.querySelector('.ds-checkbox-label__text')?.textContent).toBe('0');
73
+ });
74
+
75
+ test('CheckboxInput keeps the accessible name of a rich image label', () => {
76
+ render(
77
+ <CheckboxInput
78
+ onChange={() => {}}
79
+ label={(
80
+ <>
81
+ <img src="/email.svg" alt="Email" />
82
+ {' '}
83
+ Send to email
84
+ </>
85
+ )}
86
+ />,
87
+ );
88
+
89
+ expect(
90
+ screen.getByRole('checkbox', { name: 'Email Send to email' }),
91
+ ).toBeInTheDocument();
92
+ });
35
93
  });
@@ -1,10 +1,10 @@
1
1
  import classNames from 'classnames';
2
- import { useRef, useEffect, type InputHTMLAttributes } from 'react';
2
+ import { useRef, useEffect, type InputHTMLAttributes, type ReactNode } from 'react';
3
3
  import { Icon } from 'Components/icon/Icon';
4
4
 
5
5
  export type CheckboxInputProps = {
6
6
  indeterminate?: boolean;
7
- label?: string;
7
+ label?: ReactNode;
8
8
  hasError?: boolean;
9
9
  } & InputHTMLAttributes<HTMLInputElement>;
10
10
 
@@ -76,7 +76,7 @@ export const CheckboxInput = (props: CheckboxInputProps) => {
76
76
  />
77
77
  )}
78
78
  </span>
79
- {label && (
79
+ {label !== undefined && label !== null && label !== false && label !== '' && (
80
80
  <span className="ds-checkbox-label__text">{label}</span>
81
81
  )}
82
82
  </label>
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test, vi, beforeEach } from 'vitest';
2
- import { render, screen, fireEvent } from '@testing-library/react';
2
+ import { render, screen, fireEvent, within } from '@testing-library/react';
3
3
  import '@testing-library/jest-dom/vitest';
4
4
  import { SearchBar } from './SearchBar.js';
5
5
  import userEvent from '@testing-library/user-event';
@@ -39,14 +39,14 @@ describe('SearchBar component', () => {
39
39
  const button = screen.getByTestId('search-bar-inactive');
40
40
  fireEvent.mouseEnter(button);
41
41
  fireEvent.mouseLeave(button);
42
- expect(screen.getByText('Search')).toBeInTheDocument();
42
+ expect(within(button).getByText('Search', { selector: '.ds-search-bar--inactive__placeholder' })).toBeInTheDocument();
43
43
  });
44
44
 
45
45
  test('keeps placeholder on hover when no hoverText provided', () => {
46
46
  render(<SearchBar setSearchValue={setSearchMock} placeholderText="Search" />);
47
47
  const button = screen.getByTestId('search-bar-inactive');
48
48
  fireEvent.mouseEnter(button);
49
- expect(screen.getByText('Search')).toBeInTheDocument();
49
+ expect(within(button).getByText('Search', { selector: '.ds-search-bar--inactive__placeholder' })).toBeInTheDocument();
50
50
  });
51
51
  });
52
52
 
@@ -80,6 +80,7 @@ export const SearchBar = (props: SearchBarProps) => {
80
80
  >
81
81
  <Icon
82
82
  name="search"
83
+ screenReaderText="Search"
83
84
  size={16}
84
85
  color="var(--search-global-default-color-icon)"
85
86
  className="ds-search-bar__icon"
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export { Card, type CardProps } from 'Components/card/Card';
10
10
  export { Combobox } from 'Components/combobox/Combobox';
11
11
  export { DataViewCard, type DataViewCardProps } from 'Components/dataViewCard/DataViewCard';
12
12
  export { DatePicker } from 'Components/datePicker/DatePicker';
13
+ export { DatePickerPopover } from 'Components/datePicker/DatePickerPopover';
13
14
  export { DateTimePicker } from 'Components/dateTimePicker/DateTimePicker';
14
15
  export { Dot } from 'Components/dot/Dot';
15
16
  export { Dropdown } from 'Components/dropdown/Dropdown';
@@ -247,6 +247,7 @@ const WithCustomContainerTemplate = () => {
247
247
  minHeight: '2rem',
248
248
  }}
249
249
  aria-label="Custom popup container"
250
+ aria-hidden={true}
250
251
  />
251
252
 
252
253
  <PopupParentContext.Provider value={popupContainerRef}>