@arbor-education/design-system.components 0.25.7 → 0.25.8

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 (32) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/components/table/Table.d.ts +6 -0
  3. package/dist/components/table/Table.d.ts.map +1 -1
  4. package/dist/components/table/Table.js +3 -0
  5. package/dist/components/table/Table.js.map +1 -1
  6. package/dist/components/table/Table.stories.d.ts +1 -0
  7. package/dist/components/table/Table.stories.d.ts.map +1 -1
  8. package/dist/components/table/Table.stories.js +41 -0
  9. package/dist/components/table/Table.stories.js.map +1 -1
  10. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts +10 -0
  11. package/dist/components/table/cellRenderers/NumberInputCellRenderer.d.ts.map +1 -0
  12. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js +13 -0
  13. package/dist/components/table/cellRenderers/NumberInputCellRenderer.js.map +1 -0
  14. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts +90 -0
  15. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.d.ts.map +1 -0
  16. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js +243 -0
  17. package/dist/components/table/cellRenderers/NumberInputCellRenderer.stories.js.map +1 -0
  18. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.d.ts +2 -0
  19. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.d.ts.map +1 -0
  20. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js +72 -0
  21. package/dist/components/table/cellRenderers/NumberInputCellRenderer.test.js.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/components/table/Table.stories.tsx +45 -0
  28. package/src/components/table/Table.tsx +3 -0
  29. package/src/components/table/cellRenderers/NumberInputCellRenderer.stories.tsx +346 -0
  30. package/src/components/table/cellRenderers/NumberInputCellRenderer.test.tsx +99 -0
  31. package/src/components/table/cellRenderers/NumberInputCellRenderer.tsx +36 -0
  32. package/src/index.ts +1 -0
@@ -0,0 +1,346 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import {
4
+ Controls,
5
+ Heading as DocHeading,
6
+ Markdown,
7
+ Primary as DocPrimary,
8
+ Stories,
9
+ Subtitle,
10
+ Title,
11
+ } from '@storybook/addon-docs/blocks';
12
+ import type { CustomCellRendererProps } from 'ag-grid-react';
13
+ import { NumberInputCellRenderer } from './NumberInputCellRenderer.js';
14
+ import { Table } from 'Components/table/Table';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Docs page content
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const DESCRIPTION_INTRO = [
21
+ '`NumberInputCellRenderer` is an AG Grid cell renderer that renders an editable Arbor `NumberInput`',
22
+ 'permanently inside a table cell. On change, it calls `node.setDataValue` to update the row data in-place.',
23
+ '',
24
+ 'Register it via the string key `dsNumberInputCellRenderer` (pre-registered by Arbor\'s `Table`) or by importing the component directly.',
25
+ ].join('\n');
26
+
27
+ const USAGE_GUIDANCE = [
28
+ '### When to use',
29
+ '',
30
+ '- On numeric columns where the user edits a value directly in the table without opening a form',
31
+ '- Quantities, counts, scores, or any bounded numeric field that should be editable inline',
32
+ '',
33
+ '---',
34
+ '',
35
+ '### When NOT to use',
36
+ '',
37
+ '| Situation | Use instead |',
38
+ '|---|---|',
39
+ '| Display-only number (no editing) | Plain AG Grid value formatting or `DefaultCellRenderer` |',
40
+ '| The edit requires confirmation or a side effect | Handle in a modal; do not edit inline |',
41
+ '| Free-form decimal entry without step constraints | A form in a slideover |',
42
+ ].join('\n');
43
+
44
+ const DEVELOPER_NOTES = [
45
+ '### Critical usage patterns',
46
+ '',
47
+ '**`colDef.field` must be set for changes to persist.**',
48
+ 'On change, the renderer calls `node.setDataValue(colDef.field, parsedValue)`. If `field` is not set',
49
+ 'on the column definition, the change is silently ignored.',
50
+ '',
51
+ '**Non-finite input (empty or non-numeric) is coerced to `0`.**',
52
+ 'If the user clears the input, `setDataValue` is called with `0` rather than `NaN`.',
53
+ '',
54
+ '**Pass `min`, `max`, and `disabled` via `cellRendererParams`.**',
55
+ 'These are forwarded directly to `NumberInput`.',
56
+ '',
57
+ '```tsx',
58
+ "import { Table } from '@arbor-education/design-system.components';",
59
+ '',
60
+ 'const colDefs = [',
61
+ ' {',
62
+ " field: 'quantity',",
63
+ " headerName: 'Quantity',",
64
+ " cellRenderer: 'dsNumberInputCellRenderer',",
65
+ ' cellRendererParams: { min: 0, max: 100 },',
66
+ ' },',
67
+ '];',
68
+ '```',
69
+ '',
70
+ '---',
71
+ '',
72
+ '### TypeScript types',
73
+ '',
74
+ '```ts',
75
+ "import { NumberInputCellRenderer } from '@arbor-education/design-system.components';",
76
+ '```',
77
+ '',
78
+ '`NumberInputCellRenderer` accepts `CustomCellRendererProps` from AG Grid plus `value: number | null`,',
79
+ '`min?: number`, `max?: number`, and `disabled?: boolean` (passed via `cellRendererParams`).',
80
+ ].join('\n');
81
+
82
+ const RELATED_COMPONENTS = [
83
+ '## Related components',
84
+ '',
85
+ '[Table](?path=/docs/components-table--docs) · [NumberInput](?path=/docs/components-numberinput--docs) · [CheckboxCellRenderer](?path=/docs/components-table-cellrenderers-checkboxcellrenderer--docs)',
86
+ ].join('\n');
87
+
88
+ const PROPS_INTRO = 'The preview below uses a mock AG Grid row node. Type a value or use the +/− spinners — `node.setDataValue` is wired to local state in this preview; in a real table it updates the row data in-place.';
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Custom DocsPage
92
+ // ---------------------------------------------------------------------------
93
+
94
+ function NumberInputCellRendererDocsPage() {
95
+ return (
96
+ <>
97
+ <Title />
98
+ <Subtitle />
99
+ <Markdown>{DESCRIPTION_INTRO}</Markdown>
100
+ <DocHeading>Interactive example</DocHeading>
101
+ <Markdown>{PROPS_INTRO}</Markdown>
102
+ <DocPrimary />
103
+ <Controls />
104
+ <DocHeading>Usage guidance</DocHeading>
105
+ <Markdown>{USAGE_GUIDANCE}</Markdown>
106
+ <DocHeading>Developer notes</DocHeading>
107
+ <Markdown>{DEVELOPER_NOTES}</Markdown>
108
+ <DocHeading>Examples</DocHeading>
109
+ <Stories title="" />
110
+ <Markdown>{RELATED_COMPONENTS}</Markdown>
111
+ </>
112
+ );
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Meta
117
+ // ---------------------------------------------------------------------------
118
+
119
+ const meta = {
120
+ title: 'Components/Table/CellRenderers/NumberInputCellRenderer',
121
+ component: NumberInputCellRenderer,
122
+ tags: ['autodocs'],
123
+ parameters: {
124
+ layout: 'padded',
125
+ docs: { page: NumberInputCellRendererDocsPage },
126
+ },
127
+ argTypes: {
128
+ value: {
129
+ control: false,
130
+ description: 'Current numeric value. `null` is rendered as `0`. On change, the parsed float is written back via `node.setDataValue`.',
131
+ table: {
132
+ type: { summary: 'number | null' },
133
+ defaultValue: { summary: 'null' },
134
+ },
135
+ },
136
+ min: {
137
+ control: false,
138
+ description: 'Minimum allowed value. The minus spinner is disabled when the value reaches this bound.',
139
+ table: { type: { summary: 'number' } },
140
+ },
141
+ max: {
142
+ control: false,
143
+ description: 'Maximum allowed value. The plus spinner is disabled when the value reaches this bound.',
144
+ table: { type: { summary: 'number' } },
145
+ },
146
+ disabled: {
147
+ control: false,
148
+ description: 'When `true`, the input and spinners are non-interactive.',
149
+ table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } },
150
+ },
151
+ node: {
152
+ control: false,
153
+ description: 'AG Grid row node. The renderer calls `node.setDataValue` on each value change.',
154
+ table: { type: { summary: 'IRowNode' } },
155
+ },
156
+ colDef: {
157
+ control: false,
158
+ description: '`colDef.field` is used as the key passed to `node.setDataValue`. Changes are silently ignored when `field` is absent.',
159
+ table: { type: { summary: 'ColDef' } },
160
+ },
161
+ },
162
+ } satisfies Meta<typeof NumberInputCellRenderer>;
163
+
164
+ export default meta;
165
+ type Story = StoryObj<typeof NumberInputCellRenderer>;
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Helper
169
+ // ---------------------------------------------------------------------------
170
+
171
+ const withDescription = (story: Story, description: string): Story => ({
172
+ ...story,
173
+ parameters: {
174
+ ...story.parameters,
175
+ docs: { ...story.parameters?.docs, description: { story: description } },
176
+ },
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Template components
181
+ // ---------------------------------------------------------------------------
182
+
183
+ const InteractiveTemplate = () => {
184
+ const [count, setCount] = useState<number>(5);
185
+ const mockNode = { setDataValue: (_: unknown, val: number) => setCount(val) } as CustomCellRendererProps['node'];
186
+
187
+ return (
188
+ <NumberInputCellRenderer
189
+ {...({} as unknown as CustomCellRendererProps)}
190
+ value={count}
191
+ node={mockNode}
192
+ colDef={{ field: 'count' }}
193
+ />
194
+ );
195
+ };
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Stories
199
+ // ---------------------------------------------------------------------------
200
+
201
+ export const Default: Story = withDescription(
202
+ {
203
+ parameters: { controls: { disable: true } },
204
+ render: InteractiveTemplate,
205
+ },
206
+ 'Interactive `NumberInput` backed by a mock row node. Use the +/− spinners or type directly — `node.setDataValue` updates local state in this preview; in a real table it updates AG Grid row data in-place.',
207
+ );
208
+
209
+ export const WithMinMax: Story = withDescription(
210
+ {
211
+ parameters: { controls: { disable: true } },
212
+ render: () => {
213
+ const [val, setVal] = useState<number>(5);
214
+ const mockNode = { setDataValue: (_: unknown, v: number) => setVal(v) } as CustomCellRendererProps['node'];
215
+
216
+ return (
217
+ <NumberInputCellRenderer
218
+ {...({} as unknown as CustomCellRendererProps)}
219
+ value={val}
220
+ min={0}
221
+ max={10}
222
+ node={mockNode}
223
+ colDef={{ field: 'count' }}
224
+ />
225
+ );
226
+ },
227
+ },
228
+ 'Bounded range via `min={0}` and `max={10}`. The minus spinner disables at `0`; the plus spinner disables at `10`. Values typed outside the range are clamped on blur.',
229
+ );
230
+
231
+ export const NullValue: Story = withDescription(
232
+ {
233
+ parameters: { controls: { disable: true } },
234
+ render: () => (
235
+ <NumberInputCellRenderer
236
+ {...({} as unknown as CustomCellRendererProps)}
237
+ value={null}
238
+ node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
239
+ colDef={{ field: 'count' }}
240
+ />
241
+ ),
242
+ },
243
+ '`value={null}` is coerced to `0` before being passed to `NumberInput`. This keeps the input always controlled with a valid numeric display.',
244
+ );
245
+
246
+ export const Disabled: Story = withDescription(
247
+ {
248
+ parameters: { controls: { disable: true } },
249
+ render: () => (
250
+ <NumberInputCellRenderer
251
+ {...({} as unknown as CustomCellRendererProps)}
252
+ value={7}
253
+ disabled
254
+ node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
255
+ colDef={{ field: 'count' }}
256
+ />
257
+ ),
258
+ },
259
+ 'Disabled state — pass `disabled: true` via `cellRendererParams` to prevent editing. Useful for read-only rows or conditional editing logic.',
260
+ );
261
+
262
+ const NUMBER_INPUT_TABLE_DATA = [
263
+ { name: 'Alice Johnson', sessions: 12, score: 95 },
264
+ { name: 'Bob Smith', sessions: 8, score: 72 },
265
+ { name: 'Charlie Brown', sessions: 15, score: 88 },
266
+ { name: 'Diana Prince', sessions: 3, score: 60 },
267
+ ];
268
+
269
+ export const InATable: Story = withDescription(
270
+ {
271
+ parameters: {
272
+ controls: { disable: true },
273
+ docs: {
274
+ source: {
275
+ language: 'tsx',
276
+ code: `
277
+ import { Table } from '@arbor-education/design-system.components';
278
+
279
+ const rowData = [
280
+ { name: 'Alice Johnson', sessions: 12, score: 95 },
281
+ { name: 'Bob Smith', sessions: 8, score: 72 },
282
+ { name: 'Charlie Brown', sessions: 15, score: 88 },
283
+ { name: 'Diana Prince', sessions: 3, score: 60 },
284
+ ];
285
+
286
+ function NumberInputCellRendererExample() {
287
+ return (
288
+ <Table
289
+ rowData={rowData}
290
+ columnDefs={[
291
+ { field: 'name', headerName: 'Name', flex: 2 },
292
+ {
293
+ field: 'sessions',
294
+ headerName: 'Sessions',
295
+ flex: 1,
296
+ editable: false,
297
+ cellRenderer: 'dsNumberInputCellRenderer',
298
+ cellRendererParams: { min: 0, suppressCellFocusAndFocusFirstElement: true },
299
+ },
300
+ {
301
+ field: 'score',
302
+ headerName: 'Score (/100)',
303
+ flex: 1,
304
+ editable: false,
305
+ cellRenderer: 'dsNumberInputCellRenderer',
306
+ cellRendererParams: { min: 0, max: 100, suppressCellFocusAndFocusFirstElement: true },
307
+ },
308
+ ]}
309
+ defaultColDef={{ flex: 1, minWidth: 120 }}
310
+ domLayout="autoHeight"
311
+ />
312
+ );
313
+ }
314
+ `.trim(),
315
+ },
316
+ },
317
+ },
318
+ render: () => (
319
+ <Table
320
+ rowData={NUMBER_INPUT_TABLE_DATA}
321
+ columnDefs={[
322
+ { field: 'name', headerName: 'Name', flex: 2 },
323
+ {
324
+ field: 'sessions',
325
+ headerName: 'Sessions',
326
+ flex: 1,
327
+ editable: false,
328
+ cellRenderer: 'dsNumberInputCellRenderer',
329
+ cellRendererParams: { min: 0, suppressCellFocusAndFocusFirstElement: true },
330
+ },
331
+ {
332
+ field: 'score',
333
+ headerName: 'Score (/100)',
334
+ flex: 1,
335
+ editable: false,
336
+ cellRenderer: 'dsNumberInputCellRenderer',
337
+ cellRendererParams: { min: 0, max: 100, suppressCellFocusAndFocusFirstElement: true },
338
+ },
339
+ ]}
340
+ defaultColDef={{ flex: 1, minWidth: 120 }}
341
+ domLayout="autoHeight"
342
+ />
343
+ ),
344
+ },
345
+ '`dsNumberInputCellRenderer` wired up inside a full `Table`. Sessions has `min={0}`; Score has `min={0}` and `max={100}`. Edit any cell — AG Grid calls `node.setDataValue` which updates the row data in-place.',
346
+ );
@@ -0,0 +1,99 @@
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 userEvent from '@testing-library/user-event';
5
+ import type { CustomCellRendererProps } from 'ag-grid-react';
6
+ import { NumberInputCellRenderer } from './NumberInputCellRenderer.js';
7
+
8
+ const createMockProps = (overrides: Partial<CustomCellRendererProps & { value: number | null; min?: number; max?: number; disabled?: boolean }> = {}): CustomCellRendererProps => ({
9
+ value: 0,
10
+ node: { setDataValue: vi.fn() },
11
+ colDef: { field: 'count' },
12
+ ...overrides,
13
+ } as unknown as CustomCellRendererProps);
14
+
15
+ describe('NumberInputCellRenderer', () => {
16
+ test('renders a number input', () => {
17
+ render(<NumberInputCellRenderer {...createMockProps()} />);
18
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
19
+ });
20
+
21
+ test('renders the supplied value in the input', () => {
22
+ render(<NumberInputCellRenderer {...createMockProps({ value: 42 })} />);
23
+ expect(screen.getByRole('textbox')).toHaveValue('42');
24
+ });
25
+
26
+ test('renders null value as 0', () => {
27
+ render(<NumberInputCellRenderer {...createMockProps({ value: null })} />);
28
+ expect(screen.getByRole('textbox')).toHaveValue('0');
29
+ });
30
+
31
+ test('disables the input when disabled prop is true', () => {
32
+ render(<NumberInputCellRenderer {...createMockProps({ disabled: true })} />);
33
+ expect(screen.getByRole('textbox')).toBeDisabled();
34
+ });
35
+
36
+ test('minus button is disabled when value equals min', () => {
37
+ render(<NumberInputCellRenderer {...createMockProps({ value: 5, min: 5 })} />);
38
+ expect(screen.getByRole('button', { name: 'Minus button' })).toBeDisabled();
39
+ });
40
+
41
+ test('plus button is disabled when value equals max', () => {
42
+ render(<NumberInputCellRenderer {...createMockProps({ value: 10, max: 10 })} />);
43
+ expect(screen.getByRole('button', { name: 'Plus button' })).toBeDisabled();
44
+ });
45
+
46
+ test('calls node.setDataValue with the updated value when the input changes', async () => {
47
+ const setDataValue = vi.fn();
48
+ render(
49
+ <NumberInputCellRenderer
50
+ {...createMockProps({
51
+ value: 5,
52
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
53
+ colDef: { field: 'count' },
54
+ })}
55
+ />,
56
+ );
57
+ setDataValue.mockClear();
58
+
59
+ await userEvent.click(screen.getByRole('button', { name: 'Plus button' }));
60
+
61
+ expect(setDataValue).toHaveBeenCalledWith('count', 6);
62
+ });
63
+
64
+ test('calls node.setDataValue with 0 when the input is cleared', async () => {
65
+ const setDataValue = vi.fn();
66
+ render(
67
+ <NumberInputCellRenderer
68
+ {...createMockProps({
69
+ value: 5,
70
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
71
+ colDef: { field: 'count' },
72
+ })}
73
+ />,
74
+ );
75
+ setDataValue.mockClear();
76
+
77
+ await userEvent.clear(screen.getByRole('textbox'));
78
+
79
+ expect(setDataValue).toHaveBeenCalledWith('count', 0);
80
+ });
81
+
82
+ test('does not call node.setDataValue when colDef.field is undefined', async () => {
83
+ const setDataValue = vi.fn();
84
+ render(
85
+ <NumberInputCellRenderer
86
+ {...createMockProps({
87
+ value: 5,
88
+ node: { setDataValue } as unknown as CustomCellRendererProps['node'],
89
+ colDef: {},
90
+ })}
91
+ />,
92
+ );
93
+ setDataValue.mockClear();
94
+
95
+ await userEvent.click(screen.getByRole('button', { name: 'Plus button' }));
96
+
97
+ expect(setDataValue).not.toHaveBeenCalled();
98
+ });
99
+ });
@@ -0,0 +1,36 @@
1
+ import type { CustomCellRendererProps } from 'ag-grid-react';
2
+ import { NumberInput } from 'Components/formField/inputs/number/NumberInput';
3
+
4
+ type NumberInputCellRendererProps = {
5
+ value: number | null;
6
+ min?: number;
7
+ max?: number;
8
+ disabled?: boolean;
9
+ } & CustomCellRendererProps;
10
+
11
+ export const NumberInputCellRenderer = (
12
+ props: NumberInputCellRendererProps,
13
+ ) => {
14
+ const { value, min, max, disabled, node, colDef = {} } = props;
15
+
16
+ return (
17
+ <div className="cell-input-wrapper">
18
+ <NumberInput
19
+ min={min}
20
+ max={max}
21
+ disabled={disabled}
22
+ value={value ?? 0}
23
+ onChange={(e) => {
24
+ if (!colDef.field) {
25
+ return;
26
+ }
27
+ const next = parseFloat(e.currentTarget.value);
28
+ node.setDataValue(
29
+ colDef.field,
30
+ Number.isFinite(next) ? next : 0,
31
+ );
32
+ }}
33
+ />
34
+ </div>
35
+ );
36
+ };
package/src/index.ts CHANGED
@@ -69,6 +69,7 @@ export { DSDefaultColDef } from 'Components/table/DSDefaultColDef';
69
69
  export { GridApiContext } from 'Components/table/GridApiContext';
70
70
  export { NumberCellEditor } from 'Components/table/cellEditors/NumberCellEditor';
71
71
  export { CheckboxCellRenderer } from 'Components/table/cellRenderers/CheckboxCellRenderer';
72
+ export { NumberInputCellRenderer } from 'Components/table/cellRenderers/NumberInputCellRenderer';
72
73
  export { Table } from 'Components/table/Table';
73
74
  export { Tabs } from 'Components/tabs/Tabs';
74
75
  export { Tag } from 'Components/tag/Tag';