@arbor-education/design-system.components 4.7.1 → 4.8.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 (89) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/components/fileUpload/FileList.d.ts +7 -0
  3. package/dist/components/fileUpload/FileList.d.ts.map +1 -0
  4. package/dist/components/fileUpload/FileList.js +29 -0
  5. package/dist/components/fileUpload/FileList.js.map +1 -0
  6. package/dist/components/fileUpload/FileList.stories.d.ts +45 -0
  7. package/dist/components/fileUpload/FileList.stories.d.ts.map +1 -0
  8. package/dist/components/fileUpload/FileList.stories.js +102 -0
  9. package/dist/components/fileUpload/FileList.stories.js.map +1 -0
  10. package/dist/components/fileUpload/FileList.test.d.ts +2 -0
  11. package/dist/components/fileUpload/FileList.test.d.ts.map +1 -0
  12. package/dist/components/fileUpload/FileList.test.js +125 -0
  13. package/dist/components/fileUpload/FileList.test.js.map +1 -0
  14. package/dist/components/fileUpload/FileUpload.d.ts +12 -3
  15. package/dist/components/fileUpload/FileUpload.d.ts.map +1 -1
  16. package/dist/components/fileUpload/FileUpload.js +6 -23
  17. package/dist/components/fileUpload/FileUpload.js.map +1 -1
  18. package/dist/components/fileUpload/FileUpload.stories.d.ts +4 -1
  19. package/dist/components/fileUpload/FileUpload.stories.d.ts.map +1 -1
  20. package/dist/components/heading/Heading.d.ts +2 -387
  21. package/dist/components/heading/Heading.d.ts.map +1 -1
  22. package/dist/components/heading/Heading.js +12 -4
  23. package/dist/components/heading/Heading.js.map +1 -1
  24. package/dist/components/heading/Heading.stories.d.ts +12 -387
  25. package/dist/components/heading/Heading.stories.d.ts.map +1 -1
  26. package/dist/components/heading/Heading.stories.js +19 -0
  27. package/dist/components/heading/Heading.stories.js.map +1 -1
  28. package/dist/components/heading/Heading.test.js +24 -0
  29. package/dist/components/heading/Heading.test.js.map +1 -1
  30. package/dist/components/table/Table.d.ts +2 -0
  31. package/dist/components/table/Table.d.ts.map +1 -1
  32. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts +1 -0
  33. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.d.ts.map +1 -1
  34. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js +4 -0
  35. package/dist/components/table/cellRenderers/CheckboxCellRenderer.stories.js.map +1 -1
  36. package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js +7 -0
  37. package/dist/components/table/cellRenderers/CheckboxCellRenderer.test.js.map +1 -1
  38. package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts +1 -0
  39. package/dist/components/table/cellRenderers/InlineTextCellRenderer.d.ts.map +1 -1
  40. package/dist/components/table/cellRenderers/InlineTextCellRenderer.js +2 -2
  41. package/dist/components/table/cellRenderers/InlineTextCellRenderer.js.map +1 -1
  42. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts +14 -0
  43. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.d.ts.map +1 -1
  44. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js +12 -0
  45. package/dist/components/table/cellRenderers/InlineTextCellRenderer.stories.js.map +1 -1
  46. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts +2 -0
  47. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.d.ts.map +1 -0
  48. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js +28 -0
  49. package/dist/components/table/cellRenderers/InlineTextCellRenderer.test.js.map +1 -0
  50. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts +1 -0
  51. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts.map +1 -1
  52. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js +2 -2
  53. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js.map +1 -1
  54. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts +14 -0
  55. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts.map +1 -1
  56. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js +12 -0
  57. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js.map +1 -1
  58. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js +10 -0
  59. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js.map +1 -1
  60. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +1 -1
  61. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
  62. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts +1 -0
  63. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts.map +1 -1
  64. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js +4 -0
  65. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js.map +1 -1
  66. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js +9 -0
  67. package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js.map +1 -1
  68. package/dist/index.css +17 -0
  69. package/dist/index.css.map +1 -1
  70. package/package.json +1 -1
  71. package/src/components/fileUpload/FileList.stories.tsx +109 -0
  72. package/src/components/fileUpload/FileList.test.tsx +158 -0
  73. package/src/components/fileUpload/FileList.tsx +124 -0
  74. package/src/components/fileUpload/FileUpload.tsx +19 -108
  75. package/src/components/heading/Heading.stories.tsx +23 -0
  76. package/src/components/heading/Heading.test.tsx +38 -0
  77. package/src/components/heading/Heading.tsx +27 -3
  78. package/src/components/heading/heading.scss +18 -0
  79. package/src/components/table/cellRenderers/CheckboxCellRenderer.stories.tsx +16 -0
  80. package/src/components/table/cellRenderers/CheckboxCellRenderer.test.tsx +8 -0
  81. package/src/components/table/cellRenderers/InlineTextCellRenderer.stories.tsx +22 -0
  82. package/src/components/table/cellRenderers/InlineTextCellRenderer.test.tsx +31 -0
  83. package/src/components/table/cellRenderers/InlineTextCellRenderer.tsx +4 -1
  84. package/src/components/table/cellRenderers/NumberInputCellRenderer.stories.tsx +24 -0
  85. package/src/components/table/cellRenderers/NumberInputCellRenderer.test.tsx +13 -1
  86. package/src/components/table/cellRenderers/NumberInputCellRenderer.tsx +4 -1
  87. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.stories.tsx +19 -0
  88. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.test.tsx +11 -0
  89. package/src/components/table/cellRenderers/SelectDropdownCellRenderer.tsx +1 -1
@@ -3,18 +3,17 @@ import { Button } from 'Components/button/Button';
3
3
  import { Card } from 'Components/card/Card';
4
4
  import type { IconName } from 'Components/icon/allowedIcons';
5
5
  import { Icon } from 'Components/icon/Icon';
6
- import { Separator } from 'Components/separator/Separator';
7
6
  import { useState, type AnchorHTMLAttributes, type ReactElement, type ReactNode } from 'react';
8
7
  import Dropzone from 'react-dropzone';
9
- import { Progress } from 'Components/progress/Progress';
10
8
  import type { WidgetRenderLinkArgs } from 'Components/widget/types';
11
9
  import { FileUploadLinkModal, type FileUploadLink } from './FileUploadLinkModal.js';
10
+ import { FileList, type FileListProps as ListProps } from './FileList.js';
12
11
 
13
12
  export type { FileUploadLink };
14
13
 
15
14
  type LinkAnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href' | 'className' | 'children'>;
16
15
 
17
- type UploadedFile = {
16
+ export type UploadedFile = {
18
17
  fileName: string;
19
18
  icon?: IconName;
20
19
  fileType?: string;
@@ -24,7 +23,7 @@ type UploadedFile = {
24
23
  renderLink?: (args: WidgetRenderLinkArgs) => ReactElement;
25
24
  onEdit?: (link: FileUploadLink) => void;
26
25
  onDownload?: () => void;
27
- onDelete: () => void;
26
+ onDelete?: () => void;
28
27
  progressPercentage?: number;
29
28
  hasUploadFailed?: boolean;
30
29
  };
@@ -39,18 +38,6 @@ export type FileUploadProps = {
39
38
 
40
39
  type LinkPopupState = { mode: 'add' } | { mode: 'edit'; index: number } | null;
41
40
 
42
- const renderFileLink = ({ href = '', linkProps, renderLink }: UploadedFile, children: ReactNode) => {
43
- const className = 'ds-file-upload__file-link';
44
- if (renderLink) {
45
- return renderLink({ href, className, children, linkProps });
46
- }
47
- return (
48
- <a href={href} target="_blank" rel="noopener noreferrer" className={className} {...linkProps}>
49
- {children}
50
- </a>
51
- );
52
- };
53
-
54
41
  export const FileUpload = (props: FileUploadProps) => {
55
42
  const {
56
43
  className,
@@ -88,7 +75,7 @@ export const FileUpload = (props: FileUploadProps) => {
88
75
  </>
89
76
  );
90
77
 
91
- const editedFile = linkPopup?.mode === 'edit' ? uploadedFiles[linkPopup.index] : undefined;
78
+ const fileToEdit = linkPopup?.mode === 'edit' ? uploadedFiles[linkPopup.index] : undefined;
92
79
 
93
80
  return (
94
81
  <div
@@ -136,108 +123,32 @@ export const FileUpload = (props: FileUploadProps) => {
136
123
  onCancel={() => setLinkPopup(null)}
137
124
  />
138
125
  )}
139
- {editedFile?.href && editedFile.onEdit && (
126
+ {fileToEdit?.href && fileToEdit.onEdit && (
140
127
  <FileUploadLinkModal
141
128
  title="Update URL attachment"
142
129
  submitLabel="Update"
143
- initialLink={{ url: editedFile.href, name: editedFile.fileName }}
130
+ initialLink={{ url: fileToEdit.href, name: fileToEdit.fileName }}
144
131
  onSubmit={(link) => {
145
- editedFile.onEdit?.(link);
132
+ fileToEdit.onEdit?.(link);
146
133
  setLinkPopup(null);
147
134
  }}
148
135
  onCancel={() => setLinkPopup(null)}
149
136
  />
150
137
  )}
151
138
  {uploadedFiles.length > 0 && (
152
- <Card className="ds-file-upload__file-list">
153
- {uploadedFiles.map((file, index) => {
154
- const {
155
- fileName,
156
- fileSize,
157
- fileType,
158
- href,
159
- onEdit,
160
- onDelete,
161
- onDownload,
162
- hasUploadFailed,
163
- progressPercentage,
164
- } = file;
165
- const icon = file.icon ?? (href ? 'link' : 'file');
166
- const nameNode = <span className="ds-file-upload__file-name">{fileName}</span>;
167
- return (
168
- <div key={fileName} className="ds-file-upload__file-outer">
169
- <span className="ds-file-upload__file">
170
- <span className="ds-file-upload__icon-wrapper">
171
- <Icon size={24} name={icon} color="var(--color-brand-600)" />
172
- </span>
173
- <span className="ds-file-upload__file-details">
174
- {href ? renderFileLink(file, nameNode) : nameNode}
175
- {
176
- hasUploadFailed
177
- ? (
178
- <span className="ds-file-upload__upload-failed">
179
- <Icon name="circle-alert" />
180
- {' '}
181
- Upload failed
182
- </span>
183
- )
184
- : <span className="ds-file-upload__file-meta">{href ?? `${fileType}, ${fileSize}`}</span>
185
- }
186
- </span>
187
- {href && (
188
- <Button
189
- variant="secondary"
190
- isRound
191
- onClick={() => navigator.clipboard.writeText(href)}
192
- >
193
- <Icon name="copy" screenReaderText="Copy link" />
194
- </Button>
195
- )}
196
- {href && onEdit && (
197
- <Button
198
- variant="secondary"
199
- isRound
200
- onClick={() => setLinkPopup({ mode: 'edit', index })}
201
- >
202
- <Icon name="pencil" screenReaderText="Edit link" />
203
- </Button>
204
- )}
205
- {onDownload && (
206
- <Button
207
- variant="secondary"
208
- isRound
209
- onClick={onDownload}
210
- >
211
- <Icon name="download" screenReaderText="Download file" />
212
- </Button>
213
- )}
214
- {onDelete && (
215
- <Button
216
- variant="secondary"
217
- isRound
218
- onClick={onDelete}
219
- >
220
- <Icon name="trash" screenReaderText={href ? 'Delete link' : 'Delete file'} />
221
- </Button>
222
- )}
223
- </span>
224
- {typeof progressPercentage === 'number' && progressPercentage < 100
225
- && (
226
- <Progress
227
- value={progressPercentage}
228
- className="ds-file-upload__progress-bar"
229
- aria-label="Upload progress bar"
230
- indicatorClassName={classNames('ds-file-upload__progress-bar-indicator', {
231
- 'ds-file-upload__progress-bar-indicator--failed': hasUploadFailed,
232
- })}
233
- />
234
- )}
235
- {index < uploadedFiles.length - 1 && <Separator />}
236
- </div>
237
- );
238
- })}
239
- </Card>
139
+ <FileList
140
+ uploadedFiles={uploadedFiles}
141
+ onEditFile={index => setLinkPopup({ mode: 'edit', index })}
142
+ />
240
143
  )}
241
144
  </div>
242
145
  );
243
146
  };
147
+
148
+ FileUpload.FileList = FileList;
149
+
150
+ export namespace FileUpload {
151
+ export type Props = FileUploadProps;
152
+ export type FileListProps = ListProps;
153
+ export type File = UploadedFile;
154
+ }
@@ -256,6 +256,17 @@ const meta = {
256
256
  type: { summary: 'string' },
257
257
  },
258
258
  },
259
+ errorMessage: {
260
+ control: 'text',
261
+ description: [
262
+ 'Error message shown beneath the heading with the error icon, matching the FormField error style.',
263
+ 'Setting it also sets `aria-describedby` on the heading so screen readers announce the message with it.',
264
+ 'Write it as what went wrong plus how to fix it.',
265
+ ].join(' '),
266
+ table: {
267
+ type: { summary: 'string' },
268
+ },
269
+ },
259
270
  },
260
271
  } satisfies Meta<typeof Heading>;
261
272
 
@@ -447,6 +458,18 @@ export const Default: Story = withDescription(
447
458
  'The interactive canvas story — every prop is wired to the **Controls** panel. Use the controls to change `level` (1–4) and see the heading typography scale live. The `children` control is disabled because complex JSX cannot be round-tripped through Storybook\'s serialiser — use the named stories below to explore composition patterns.',
448
459
  );
449
460
 
461
+ export const WithError: Story = withDescription(
462
+ {
463
+ args: {
464
+ level: 1,
465
+ children: 'Student records',
466
+ errorMessage: 'Select a school before saving these records',
467
+ },
468
+ render: args => <Heading {...args} />,
469
+ },
470
+ 'Error state — setting `errorMessage` shows the message beneath the heading with the error icon, and links it to the heading through `aria-describedby`. Use it for a page-level problem that the user must fix, such as a missing required selection.',
471
+ );
472
+
450
473
  export const AllLevels: Story = withDescription(
451
474
  {
452
475
  render: AllLevelsTemplate,
@@ -26,4 +26,42 @@ describe('Heading', () => {
26
26
 
27
27
  expect(screen.getByText('foobar').tagName).toBe('H2');
28
28
  });
29
+
30
+ test('renders the error message when errorMessage is set', () => {
31
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
32
+
33
+ expect(screen.getByText('Enter a student name')).toBeInTheDocument();
34
+ });
35
+
36
+ test('links the heading to the error message with aria-describedby', () => {
37
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
38
+
39
+ const heading = screen.getByRole('heading', { name: 'Student' });
40
+ const message = screen.getByText('Enter a student name');
41
+ expect(heading).toHaveAttribute('aria-describedby', message.id);
42
+ });
43
+
44
+ test('preserves an existing aria-describedby alongside the error message id', () => {
45
+ render(
46
+ <Heading errorMessage="Enter a student name" aria-describedby="hint">
47
+ Student
48
+ </Heading>,
49
+ );
50
+
51
+ const message = screen.getByText('Enter a student name');
52
+ expect(screen.getByRole('heading')).toHaveAttribute('aria-describedby', `hint ${message.id}`);
53
+ });
54
+
55
+ test('does not render an error or aria-describedby when errorMessage is not set', () => {
56
+ render(<Heading>Student</Heading>);
57
+
58
+ expect(screen.getByRole('heading')).not.toHaveAttribute('aria-describedby');
59
+ expect(document.querySelector('.ds-heading__error-message')).not.toBeInTheDocument();
60
+ });
61
+
62
+ test('does not forward errorMessage to the DOM element', () => {
63
+ render(<Heading errorMessage="Enter a student name">Student</Heading>);
64
+
65
+ expect(screen.getByRole('heading')).not.toHaveAttribute('errormessage');
66
+ });
29
67
  });
@@ -1,5 +1,6 @@
1
1
  import classNames from 'classnames';
2
- import { createElement, type HTMLAttributes, type HTMLProps } from 'react';
2
+ import { createElement, useId, type HTMLAttributes, type HTMLProps } from 'react';
3
+ import { Icon } from 'Components/icon/Icon';
3
4
 
4
5
  type HeadingSubContainerProps = HTMLAttributes<HTMLSpanElement>;
5
6
 
@@ -21,10 +22,12 @@ export type HeadingLevel = 1 | 2 | 3 | 4;
21
22
 
22
23
  type HeadingProps = {
23
24
  level?: HeadingLevel;
25
+ errorMessage?: string;
24
26
  } & HTMLProps<HTMLHeadingElement>;
25
27
 
26
28
  export const Heading = (props: HeadingProps) => {
27
- const { level = 1, children } = props;
29
+ const { level = 1, children, errorMessage, ...rest } = props;
30
+ const generatedId = useId();
28
31
  const Component = {
29
32
  1: 'h1',
30
33
  2: 'h2',
@@ -32,7 +35,28 @@ export const Heading = (props: HeadingProps) => {
32
35
  4: 'h4',
33
36
  }[level];
34
37
 
35
- return createElement(Component, { ...props, className: classNames('ds-heading', props.className) }, children);
38
+ const errorId = `${generatedId}-error`;
39
+ const describedBy = classNames(rest['aria-describedby'], errorMessage && errorId) || undefined;
40
+
41
+ const heading = createElement(
42
+ Component,
43
+ { ...rest, 'aria-describedby': describedBy, 'className': classNames('ds-heading', rest.className) },
44
+ children,
45
+ );
46
+
47
+ if (!errorMessage) {
48
+ return heading;
49
+ }
50
+
51
+ return (
52
+ <div className="ds-heading__wrapper">
53
+ {heading}
54
+ <span className="ds-heading__error-message" id={errorId}>
55
+ <Icon size={12} name="triangle-alert" />
56
+ {errorMessage}
57
+ </span>
58
+ </div>
59
+ );
36
60
  };
37
61
 
38
62
  Heading.InnerContainer = HeadingInnerContainer;
@@ -16,6 +16,24 @@
16
16
  }
17
17
  }
18
18
 
19
+ .ds-heading__wrapper {
20
+ display: flex;
21
+ flex-direction: column;
22
+ width: 100%;
23
+ }
24
+
25
+ .ds-heading__error-message {
26
+ display: flex;
27
+ align-items: center;
28
+ gap: var(--form-field-spacing-horizontal);
29
+ padding: 0 var(--page-heading-spacing-gap) var(--page-heading-spacing-gap);
30
+ color: var(--form-field-text-error-color-error-text);
31
+
32
+ .ds-icon {
33
+ color: var(--form-field-text-error-color-error-icon);
34
+ }
35
+ }
36
+
19
37
  h1 {
20
38
  &.ds-heading, &.ds-heading .ds-heading__inner-container, .ds-editable-text__button, .ds-editable-text__input {
21
39
  font-family: var(--type-headings-h1-family);
@@ -267,6 +267,22 @@ export const DisabledChecked: Story = withDescription(
267
267
  'Disabled state — pass `disabled: true` via `cellRendererParams` to prevent the user from toggling the value. Useful for read-only rows or conditional editing logic.',
268
268
  );
269
269
 
270
+ export const WithError: Story = withDescription(
271
+ {
272
+ parameters: { controls: { disable: true } },
273
+ render: () => (
274
+ <CheckboxCellRenderer
275
+ {...({} as unknown as CustomCellRendererProps)}
276
+ value={false}
277
+ node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
278
+ column={{} as CustomCellRendererProps['column']}
279
+ colDef={{ headerName: 'Enrolled', cellRendererParams: { hasError: true } }}
280
+ />
281
+ ),
282
+ },
283
+ 'Error state — pass `hasError: true` via `cellRendererParams` to flag the checkbox. It forwards to `CheckboxInput`, which sets `aria-invalid="true"` and the error styling. Use it to show a required acknowledgement that has not been given.',
284
+ );
285
+
270
286
  const CHECKBOX_IN_TABLE_DATA = [
271
287
  { name: 'Alice Johnson', enrolled: true },
272
288
  { name: 'Bob Smith', enrolled: false },
@@ -33,6 +33,14 @@ describe('CheckboxCellRenderer', () => {
33
33
  expect(screen.getByRole('checkbox')).toBeChecked();
34
34
  });
35
35
 
36
+ test('sets aria-invalid when hasError is passed via cellRendererParams', () => {
37
+ const props = createMockProps({
38
+ colDef: { cellRendererParams: { hasError: true } },
39
+ });
40
+ render(<CheckboxCellRenderer {...props} />);
41
+ expect(screen.getByRole('checkbox')).toHaveAttribute('aria-invalid', 'true');
42
+ });
43
+
36
44
  test('calls node.setDataValue with toggled value on change', async () => {
37
45
  const setDataValue = vi.fn();
38
46
  const props = createMockProps({
@@ -158,6 +158,14 @@ const meta = {
158
158
  description: 'AG Grid column instance. Passed as the first argument to `node.setDataValue`.',
159
159
  table: { type: { summary: 'Column | null' } },
160
160
  },
161
+ hasError: {
162
+ control: 'boolean',
163
+ description: 'Applies the error style to the `TextInput` and sets `aria-invalid`. Pass via `cellRendererParams` (or a `cellRendererParams` function) to flag a cell. Visual and a11y only — the error message itself is not rendered by the cell.',
164
+ table: {
165
+ type: { summary: 'boolean' },
166
+ defaultValue: { summary: 'false' },
167
+ },
168
+ },
161
169
  },
162
170
  } satisfies Meta<typeof InlineTextCellRenderer>;
163
171
 
@@ -193,6 +201,20 @@ export const Default: Story = withDescription(
193
201
  'Pre-populated with an existing value. The `TextInput` is always visible — type to update the internal value, then press Enter to commit (in a real table, Enter calls `node.setDataValue`).',
194
202
  );
195
203
 
204
+ export const WithError: Story = withDescription(
205
+ {
206
+ parameters: { controls: { disable: true } },
207
+ render: () => (
208
+ <InlineTextCellRenderer
209
+ {...MOCK_CELL_PROPS}
210
+ value=""
211
+ hasError
212
+ />
213
+ ),
214
+ },
215
+ 'Error state — `hasError` applies the error border to the `TextInput` and sets `aria-invalid="true"`. Pass it via `cellRendererParams` to flag a cell, for example when a required value is missing. The error message itself is not rendered in the cell; surface it through the grid\'s own validation or a tooltip if users need the wording.',
216
+ );
217
+
196
218
  export const Empty: Story = withDescription(
197
219
  {
198
220
  parameters: {
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom/vitest';
4
+ import type { CustomCellRendererProps } from 'ag-grid-react';
5
+ import { InlineTextCellRenderer } from './InlineTextCellRenderer.js';
6
+
7
+ const createMockProps = (overrides: Partial<CustomCellRendererProps> & { hasError?: boolean } = {}) => ({
8
+ value: 'Alice',
9
+ node: { setDataValue: vi.fn() },
10
+ column: 'name',
11
+ ...overrides,
12
+ } as unknown as CustomCellRendererProps & { value: string; hasError?: boolean });
13
+
14
+ describe('InlineTextCellRenderer', () => {
15
+ test('renders a text input with the supplied value', () => {
16
+ render(<InlineTextCellRenderer {...createMockProps()} />);
17
+ expect(screen.getByRole('textbox')).toHaveValue('Alice');
18
+ });
19
+
20
+ test('applies the error style and aria-invalid when hasError is true', () => {
21
+ render(<InlineTextCellRenderer {...createMockProps({ hasError: true })} />);
22
+ expect(screen.getByRole('textbox')).toHaveClass('ds-input--error');
23
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
24
+ });
25
+
26
+ test('does not apply the error style when hasError is not set', () => {
27
+ render(<InlineTextCellRenderer {...createMockProps()} />);
28
+ expect(screen.getByRole('textbox')).not.toHaveClass('ds-input--error');
29
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid', 'true');
30
+ });
31
+ });
@@ -5,10 +5,11 @@ import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
5
5
 
6
6
  type InlineTextCellRendererProps = CustomCellRendererProps & {
7
7
  value: string;
8
+ hasError?: boolean;
8
9
  };
9
10
 
10
11
  export const InlineTextCellRenderer = (props: InlineTextCellRendererProps) => {
11
- const { value, valueFormatted, column, node } = props;
12
+ const { value, valueFormatted, column, node, hasError } = props;
12
13
  const [valueToRender, setValueToRender] = useState(String(valueFormatted ?? value ?? ''));
13
14
  const valueRef = useRef(valueToRender);
14
15
  const wrapperRef = useRef<HTMLDivElement>(null);
@@ -41,6 +42,8 @@ export const InlineTextCellRenderer = (props: InlineTextCellRendererProps) => {
41
42
  <TextInput
42
43
  value={valueToRender}
43
44
  onChange={handleChange}
45
+ hasError={hasError}
46
+ aria-invalid={hasError}
44
47
  />
45
48
  </div>
46
49
  );
@@ -159,6 +159,14 @@ const meta = {
159
159
  description: '`colDef.field` is used as the key passed to `node.setDataValue`. Changes are silently ignored when `field` is absent.',
160
160
  table: { type: { summary: 'ColDef' } },
161
161
  },
162
+ hasError: {
163
+ control: 'boolean',
164
+ description: 'Applies the error style to the input container and sets `aria-invalid`. Pass via `cellRendererParams` (or a `cellRendererParams` function) to flag a cell. Visual and a11y only — the error message itself is not rendered by the cell.',
165
+ table: {
166
+ type: { summary: 'boolean' },
167
+ defaultValue: { summary: 'false' },
168
+ },
169
+ },
162
170
  },
163
171
  } satisfies Meta<typeof NumberInputCellRenderer>;
164
172
 
@@ -244,6 +252,22 @@ export const NullValue: Story = withDescription(
244
252
  '`value={null}` is coerced to `0` before being passed to `NumberInput`. This keeps the input always controlled with a valid numeric display.',
245
253
  );
246
254
 
255
+ export const WithError: Story = withDescription(
256
+ {
257
+ parameters: { controls: { disable: true } },
258
+ render: () => (
259
+ <NumberInputCellRenderer
260
+ {...({} as unknown as CustomCellRendererProps)}
261
+ value={0}
262
+ hasError
263
+ node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
264
+ colDef={{ field: 'count' }}
265
+ />
266
+ ),
267
+ },
268
+ 'Error state — `hasError` adds the error border to the input container and sets `aria-invalid="true"`. Pass it via `cellRendererParams` to flag a cell, for example when the value falls outside an allowed range. The error message itself is not rendered in the cell.',
269
+ );
270
+
247
271
  export const Disabled: Story = withDescription(
248
272
  {
249
273
  parameters: { controls: { disable: true } },
@@ -5,7 +5,7 @@ import userEvent from '@testing-library/user-event';
5
5
  import type { CustomCellRendererProps } from 'ag-grid-react';
6
6
  import { NumberInputCellRenderer } from './NumberInputCellRenderer.js';
7
7
 
8
- const createMockProps = (overrides: Partial<CustomCellRendererProps & { value: number | null; min?: number; max?: number; disabled?: boolean }> = {}): CustomCellRendererProps => ({
8
+ const createMockProps = (overrides: Partial<CustomCellRendererProps & { value: number | null; min?: number; max?: number; disabled?: boolean; hasError?: boolean }> = {}): CustomCellRendererProps => ({
9
9
  value: 0,
10
10
  node: { setDataValue: vi.fn() },
11
11
  colDef: { field: 'count' },
@@ -28,6 +28,18 @@ describe('NumberInputCellRenderer', () => {
28
28
  expect(screen.getByRole('textbox')).toHaveValue('0');
29
29
  });
30
30
 
31
+ test('applies the error style and aria-invalid when hasError is true', () => {
32
+ render(<NumberInputCellRenderer {...createMockProps({ hasError: true })} />);
33
+ expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true');
34
+ expect(screen.getByRole('textbox').closest('.ds-number-input__container')).toHaveClass('ds-number-input__container--error');
35
+ });
36
+
37
+ test('does not apply the error style when hasError is not set', () => {
38
+ render(<NumberInputCellRenderer {...createMockProps()} />);
39
+ expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-invalid', 'true');
40
+ expect(screen.getByRole('textbox').closest('.ds-number-input__container')).not.toHaveClass('ds-number-input__container--error');
41
+ });
42
+
31
43
  test('disables the input when disabled prop is true', () => {
32
44
  render(<NumberInputCellRenderer {...createMockProps({ disabled: true })} />);
33
45
  expect(screen.getByRole('textbox')).toBeDisabled();
@@ -6,12 +6,13 @@ type NumberInputCellRendererProps = {
6
6
  min?: number;
7
7
  max?: number;
8
8
  disabled?: boolean;
9
+ hasError?: boolean;
9
10
  } & CustomCellRendererProps;
10
11
 
11
12
  export const NumberInputCellRenderer = (
12
13
  props: NumberInputCellRendererProps,
13
14
  ) => {
14
- const { value, min, max, disabled, node, colDef = {} } = props;
15
+ const { value, min, max, disabled, hasError, node, colDef = {} } = props;
15
16
 
16
17
  return (
17
18
  <div className="cell-input-wrapper">
@@ -19,6 +20,8 @@ export const NumberInputCellRenderer = (
19
20
  min={min}
20
21
  max={max}
21
22
  disabled={disabled}
23
+ hasError={hasError}
24
+ aria-invalid={hasError}
22
25
  value={value ?? 0}
23
26
  onChange={(e) => {
24
27
  if (!colDef.field) {
@@ -326,6 +326,25 @@ export const Disabled: Story = withDescription(
326
326
  'Pass `disabled: true` via `cellRendererParams` to prevent the user from changing the selection for specific rows.',
327
327
  );
328
328
 
329
+ export const WithError: Story = withDescription(
330
+ {
331
+ parameters: { controls: { disable: true } },
332
+ render: () => (
333
+ <SelectDropdownCellRenderer
334
+ {...MOCK_CELL_PROPS}
335
+ api={MOCK_API}
336
+ value=""
337
+ options={STATUS_OPTIONS}
338
+ placeholder="Select status"
339
+ hasError={true}
340
+ node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
341
+ column={{} as CustomCellRendererProps['column']}
342
+ />
343
+ ),
344
+ },
345
+ 'Error state — pass `hasError: true` via `cellRendererParams` to flag the trigger. It applies the error style and sets `aria-invalid="true"` for screen readers automatically. The error message itself is not rendered in the cell.',
346
+ );
347
+
329
348
  export const WithBackgroundColor: Story = withDescription(
330
349
  {
331
350
  parameters: { controls: { disable: true } },
@@ -38,6 +38,17 @@ describe('SelectDropdownCellRenderer', () => {
38
38
  expect(screen.getByRole('button')).toBeInTheDocument();
39
39
  });
40
40
 
41
+ test('applies the error style and aria-invalid when hasError is true', () => {
42
+ render(<SelectDropdownCellRenderer {...createMockProps()} hasError={true} />);
43
+ expect(screen.getByRole('button')).toHaveClass('ds-button--error');
44
+ expect(screen.getByRole('button')).toHaveAttribute('aria-invalid', 'true');
45
+ });
46
+
47
+ test('does not apply the error style when hasError is not set', () => {
48
+ render(<SelectDropdownCellRenderer {...createMockProps()} />);
49
+ expect(screen.getByRole('button')).not.toHaveClass('ds-button--error');
50
+ });
51
+
41
52
  describe('backgroundColor', () => {
42
53
  test('does not apply the custom-bg or text-contrast modifiers when not provided', () => {
43
54
  const { container } = render(<SelectDropdownCellRenderer {...createMockProps()} />);
@@ -47,7 +47,7 @@ export const SelectDropdownCellRenderer = (
47
47
  hasError,
48
48
  id,
49
49
  'aria-describedBy': ariaDescribedBy,
50
- 'aria-invalid': ariaInvalid,
50
+ 'aria-invalid': ariaInvalid = hasError,
51
51
  alwaysShowPlaceholder = false,
52
52
  backgroundColor,
53
53
  fillCell = false,