@arbor-education/design-system.components 0.17.1 → 0.19.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 +12 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.d.ts.map +1 -1
- package/dist/components/combobox/useComboboxListboxKeyboard.js +2 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.js.map +1 -1
- package/dist/components/combobox/useComboboxListboxKeyboard.test.js +1 -0
- package/dist/components/combobox/useComboboxListboxKeyboard.test.js.map +1 -1
- package/dist/components/table/Table.d.ts +11 -1
- package/dist/components/table/Table.d.ts.map +1 -1
- package/dist/components/table/Table.js +3 -0
- package/dist/components/table/Table.js.map +1 -1
- package/dist/components/table/Table.stories.d.ts +2 -0
- package/dist/components/table/Table.stories.d.ts.map +1 -1
- package/dist/components/table/Table.stories.js +148 -2
- package/dist/components/table/Table.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.d.ts +13 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.js +72 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.js.map +1 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts +2 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js +324 -0
- package/dist/components/table/cellRenderers/ComboboxCellRenderer.test.js.map +1 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.d.ts +4 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js +13 -2
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.js.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts +31 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.d.ts.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js +83 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.stories.js.map +1 -1
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.d.ts +2 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.d.ts.map +1 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js +84 -0
- package/dist/components/table/cellRenderers/SelectDropdownCellRenderer.test.js.map +1 -0
- package/dist/index.css +23 -0
- package/dist/index.css.map +1 -1
- package/dist/utils/getTextContrast.d.ts +4 -0
- package/dist/utils/getTextContrast.d.ts.map +1 -0
- package/dist/utils/getTextContrast.js +14 -0
- package/dist/utils/getTextContrast.js.map +1 -0
- package/dist/utils/getTextContrast.test.d.ts +2 -0
- package/dist/utils/getTextContrast.test.d.ts.map +1 -0
- package/dist/utils/getTextContrast.test.js +68 -0
- package/dist/utils/getTextContrast.test.js.map +1 -0
- package/package.json +1 -1
- package/src/components/combobox/useComboboxListboxKeyboard.test.tsx +1 -0
- package/src/components/combobox/useComboboxListboxKeyboard.ts +2 -0
- package/src/components/table/Table.stories.tsx +180 -2
- package/src/components/table/Table.tsx +3 -0
- package/src/components/table/cellRenderers/ComboboxCellRenderer.test.tsx +437 -0
- package/src/components/table/cellRenderers/ComboboxCellRenderer.tsx +135 -0
- package/src/components/table/cellRenderers/SelectDropdownCellRenderer.stories.tsx +139 -0
- package/src/components/table/cellRenderers/SelectDropdownCellRenderer.test.tsx +118 -0
- package/src/components/table/cellRenderers/SelectDropdownCellRenderer.tsx +22 -2
- package/src/components/table/table.scss +31 -0
- package/src/utils/getTextContrast.test.ts +82 -0
- package/src/utils/getTextContrast.ts +14 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import type {
|
|
3
|
+
CellKeyDownEvent,
|
|
4
|
+
FullWidthCellKeyDownEvent,
|
|
5
|
+
SuppressKeyboardEventParams,
|
|
6
|
+
} from 'ag-grid-community';
|
|
7
|
+
import type { CustomCellRendererProps } from 'ag-grid-react';
|
|
8
|
+
import { Combobox } from 'Components/combobox/Combobox';
|
|
9
|
+
import type { ComboboxProps } from 'Components/combobox/types';
|
|
10
|
+
import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
|
|
11
|
+
|
|
12
|
+
type ComboboxCellRendererProps = CustomCellRendererProps & ComboboxProps;
|
|
13
|
+
|
|
14
|
+
export const ComboboxCellRenderer = (props: ComboboxCellRendererProps) => {
|
|
15
|
+
const {
|
|
16
|
+
value,
|
|
17
|
+
node,
|
|
18
|
+
column,
|
|
19
|
+
api,
|
|
20
|
+
options = [],
|
|
21
|
+
multiple = false,
|
|
22
|
+
placeholder,
|
|
23
|
+
triggerVariant,
|
|
24
|
+
disabled,
|
|
25
|
+
hasError,
|
|
26
|
+
searchType,
|
|
27
|
+
highlightStringMatches,
|
|
28
|
+
loading,
|
|
29
|
+
onSearch,
|
|
30
|
+
id,
|
|
31
|
+
'aria-describedby': ariaDescribedBy,
|
|
32
|
+
'aria-invalid': ariaInvalid,
|
|
33
|
+
'aria-label': ariaLabel,
|
|
34
|
+
} = props;
|
|
35
|
+
|
|
36
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
|
|
38
|
+
const normalizedValue: string[] = Array.isArray(value)
|
|
39
|
+
? value.map(String)
|
|
40
|
+
: value != null && value !== ''
|
|
41
|
+
? [String(value)]
|
|
42
|
+
: [];
|
|
43
|
+
|
|
44
|
+
useComponentDidMount(() => {
|
|
45
|
+
// open the combobox when the user presses Enter on the ag-grid cell
|
|
46
|
+
const handleCellKeyDown = (
|
|
47
|
+
event: CellKeyDownEvent | FullWidthCellKeyDownEvent,
|
|
48
|
+
) => {
|
|
49
|
+
const { key } = event.event as KeyboardEvent;
|
|
50
|
+
if (
|
|
51
|
+
'column' in event
|
|
52
|
+
&& event.node === node
|
|
53
|
+
&& event.column === column
|
|
54
|
+
&& key === 'Enter'
|
|
55
|
+
) {
|
|
56
|
+
const trigger = containerRef.current?.querySelector<HTMLElement>(
|
|
57
|
+
'[role="combobox"], [role="button"][aria-haspopup="listbox"]',
|
|
58
|
+
);
|
|
59
|
+
if (!trigger) return;
|
|
60
|
+
trigger.focus();
|
|
61
|
+
// button-style triggers (single-select) need an explicit click to open the popover
|
|
62
|
+
if (trigger.getAttribute('role') === 'button') {
|
|
63
|
+
trigger.click();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
api.addEventListener('cellKeyDown', handleCellKeyDown);
|
|
69
|
+
|
|
70
|
+
// restore ag-grid cell focus when popover closes without focus leaving the cell
|
|
71
|
+
const container = containerRef.current;
|
|
72
|
+
let observer: MutationObserver | undefined;
|
|
73
|
+
if (container) {
|
|
74
|
+
const trigger = container.querySelector<HTMLElement>(
|
|
75
|
+
'[role="combobox"], [role="button"][aria-haspopup="listbox"]',
|
|
76
|
+
);
|
|
77
|
+
if (trigger) {
|
|
78
|
+
observer = new MutationObserver(() => {
|
|
79
|
+
const isOpen = trigger.getAttribute('aria-expanded') === 'true';
|
|
80
|
+
if (isOpen || column == null || node.rowIndex == null) return;
|
|
81
|
+
const active = document.activeElement;
|
|
82
|
+
const focusIsStillInCell = active == null || active === document.body || container.contains(active);
|
|
83
|
+
if (focusIsStillInCell) {
|
|
84
|
+
api.setFocusedCell(node.rowIndex, column);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
observer.observe(trigger, { attributes: true, attributeFilter: ['aria-expanded'] });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return () => {
|
|
92
|
+
api.removeEventListener('cellKeyDown', handleCellKeyDown);
|
|
93
|
+
observer?.disconnect();
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div ref={containerRef} className="ds-table__combobox">
|
|
99
|
+
<Combobox
|
|
100
|
+
options={options}
|
|
101
|
+
value={normalizedValue}
|
|
102
|
+
multiple={multiple}
|
|
103
|
+
placeholder={placeholder}
|
|
104
|
+
triggerVariant={triggerVariant}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
hasError={hasError}
|
|
107
|
+
searchType={searchType}
|
|
108
|
+
highlightStringMatches={highlightStringMatches}
|
|
109
|
+
loading={loading}
|
|
110
|
+
onSearch={onSearch}
|
|
111
|
+
id={id}
|
|
112
|
+
aria-describedby={ariaDescribedBy}
|
|
113
|
+
aria-invalid={ariaInvalid}
|
|
114
|
+
aria-label={ariaLabel}
|
|
115
|
+
onValueChange={(newValues) => {
|
|
116
|
+
if (column) {
|
|
117
|
+
node.setDataValue(column, multiple ? newValues : (newValues[0] ?? null));
|
|
118
|
+
}
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
ComboboxCellRenderer.colDefDefaults = {
|
|
126
|
+
autoHeight: true,
|
|
127
|
+
suppressKeyboardEvent: (params: SuppressKeyboardEventParams) => {
|
|
128
|
+
const cell = (params.event.target as HTMLElement)?.closest('.ag-cell');
|
|
129
|
+
if (!cell) return false;
|
|
130
|
+
const openTrigger = cell.querySelector(
|
|
131
|
+
'[role="combobox"][aria-expanded="true"], [role="button"][aria-expanded="true"]',
|
|
132
|
+
);
|
|
133
|
+
return !!openTrigger && ['ArrowUp', 'ArrowDown', 'Enter', 'Escape'].includes(params.event.key);
|
|
134
|
+
},
|
|
135
|
+
};
|
|
@@ -187,6 +187,22 @@ const meta = {
|
|
|
187
187
|
description: 'AG Grid row node. The renderer calls `node.setDataValue` on option selection.',
|
|
188
188
|
table: { type: { summary: 'IRowNode' } },
|
|
189
189
|
},
|
|
190
|
+
backgroundColor: {
|
|
191
|
+
control: 'color',
|
|
192
|
+
description: 'Optional hex colour applied to the dropdown trigger background. Text colour automatically switches between dark and white based on YIQ luminance for legibility.',
|
|
193
|
+
table: {
|
|
194
|
+
type: { summary: 'string' },
|
|
195
|
+
defaultValue: { summary: 'undefined' },
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
fillCell: {
|
|
199
|
+
control: 'boolean',
|
|
200
|
+
description: 'When `true` the wrapper and trigger button stretch to 100% of the cell, and the trigger\'s rounded corners are removed. For true edge-to-edge fill, also set `cellStyle: { padding: 0 }` on the column def — AG Grid\'s default cell padding lives outside the renderer.',
|
|
201
|
+
table: {
|
|
202
|
+
type: { summary: 'boolean' },
|
|
203
|
+
defaultValue: { summary: 'false' },
|
|
204
|
+
},
|
|
205
|
+
},
|
|
190
206
|
},
|
|
191
207
|
} satisfies Meta<typeof SelectDropdownCellRenderer>;
|
|
192
208
|
|
|
@@ -290,6 +306,58 @@ export const Disabled: Story = withDescription(
|
|
|
290
306
|
'Pass `disabled: true` via `cellRendererParams` to prevent the user from changing the selection for specific rows.',
|
|
291
307
|
);
|
|
292
308
|
|
|
309
|
+
export const WithBackgroundColor: Story = withDescription(
|
|
310
|
+
{
|
|
311
|
+
parameters: { controls: { disable: true } },
|
|
312
|
+
render: () => (
|
|
313
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, alignItems: 'flex-start' }}>
|
|
314
|
+
<SelectDropdownCellRenderer
|
|
315
|
+
{...MOCK_CELL_PROPS}
|
|
316
|
+
api={MOCK_API}
|
|
317
|
+
value="active"
|
|
318
|
+
options={STATUS_OPTIONS}
|
|
319
|
+
placeholder="Select status"
|
|
320
|
+
backgroundColor="#1F7A1F"
|
|
321
|
+
node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
|
|
322
|
+
column={{} as CustomCellRendererProps['column']}
|
|
323
|
+
/>
|
|
324
|
+
<SelectDropdownCellRenderer
|
|
325
|
+
{...MOCK_CELL_PROPS}
|
|
326
|
+
api={MOCK_API}
|
|
327
|
+
value="pending"
|
|
328
|
+
options={STATUS_OPTIONS}
|
|
329
|
+
placeholder="Select status"
|
|
330
|
+
backgroundColor="#FFE68A"
|
|
331
|
+
node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
|
|
332
|
+
column={{} as CustomCellRendererProps['column']}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
),
|
|
336
|
+
},
|
|
337
|
+
'Pass a hex `backgroundColor` via `cellRendererParams` to tint the trigger. The text colour automatically switches between dark (`#2F2F2F`) and white (`#FFFFFF`) based on the YIQ luminance of the background so the label remains legible. Top row: dark green → white text. Bottom row: pale yellow → dark text.',
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
export const FillCell: Story = withDescription(
|
|
341
|
+
{
|
|
342
|
+
parameters: { controls: { disable: true } },
|
|
343
|
+
render: () => (
|
|
344
|
+
<div style={{ width: 240, height: 40, border: '1px dashed var(--color-grey-300)' }}>
|
|
345
|
+
<SelectDropdownCellRenderer
|
|
346
|
+
{...MOCK_CELL_PROPS}
|
|
347
|
+
api={MOCK_API}
|
|
348
|
+
value="active"
|
|
349
|
+
options={STATUS_OPTIONS}
|
|
350
|
+
placeholder="Select status"
|
|
351
|
+
fillCell={true}
|
|
352
|
+
node={{ setDataValue: () => {} } as unknown as CustomCellRendererProps['node']}
|
|
353
|
+
column={{} as CustomCellRendererProps['column']}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
),
|
|
357
|
+
},
|
|
358
|
+
'`fillCell: true` stretches the wrapper and trigger to 100% of the container and removes the trigger\'s rounded corners — typical when the dropdown should look like part of the cell rather than a button placed inside it. The dashed box here represents the surrounding cell.',
|
|
359
|
+
);
|
|
360
|
+
|
|
293
361
|
const SELECT_IN_TABLE_DATA = [
|
|
294
362
|
{ name: 'Alice Johnson', status: 'active' },
|
|
295
363
|
{ name: 'Bob Smith', status: 'inactive' },
|
|
@@ -367,3 +435,74 @@ export default SelectDropdownCellRendererExample;
|
|
|
367
435
|
},
|
|
368
436
|
'`dsSelectDropdownCellRenderer` wired up inside a full `Table`. Click a Status dropdown to select a new value — the selection is committed to the row via `node.setDataValue`. Press Enter in a focused Status cell to open the dropdown via keyboard.',
|
|
369
437
|
);
|
|
438
|
+
|
|
439
|
+
const STATUS_BACKGROUND_COLORS: Record<string, string> = {
|
|
440
|
+
active: '#1F7A1F',
|
|
441
|
+
inactive: '#B91C1C',
|
|
442
|
+
pending: '#FFE68A',
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
export const ColoredCellsInATable: Story = withDescription(
|
|
446
|
+
{
|
|
447
|
+
parameters: {
|
|
448
|
+
controls: { disable: true },
|
|
449
|
+
docs: {
|
|
450
|
+
source: {
|
|
451
|
+
language: 'tsx',
|
|
452
|
+
code: `
|
|
453
|
+
import { Table } from '@arbor-education/design-system.components';
|
|
454
|
+
|
|
455
|
+
const STATUS_BACKGROUND_COLORS = {
|
|
456
|
+
active: '#1F7A1F',
|
|
457
|
+
inactive: '#B91C1C',
|
|
458
|
+
pending: '#FFE68A',
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const columnDefs = [
|
|
462
|
+
{ field: 'name', headerName: 'Name', flex: 2 },
|
|
463
|
+
{
|
|
464
|
+
field: 'status',
|
|
465
|
+
headerName: 'Status',
|
|
466
|
+
flex: 1,
|
|
467
|
+
editable: false,
|
|
468
|
+
cellRenderer: 'dsSelectDropdownCellRenderer',
|
|
469
|
+
// Per-row params: derive the cell background from the row's current value.
|
|
470
|
+
cellRendererParams: (params) => ({
|
|
471
|
+
options: statusOptions,
|
|
472
|
+
backgroundColor: STATUS_BACKGROUND_COLORS[params.value],
|
|
473
|
+
fillCell: true,
|
|
474
|
+
}),
|
|
475
|
+
// fillCell wants a flush cell — strip AG Grid's default cell padding.
|
|
476
|
+
cellStyle: { padding: 0 },
|
|
477
|
+
},
|
|
478
|
+
];
|
|
479
|
+
`.trim(),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
render: () => (
|
|
484
|
+
<Table
|
|
485
|
+
rowData={SELECT_IN_TABLE_DATA}
|
|
486
|
+
columnDefs={[
|
|
487
|
+
{ field: 'name', headerName: 'Name', flex: 2 },
|
|
488
|
+
{
|
|
489
|
+
field: 'status',
|
|
490
|
+
headerName: 'Status',
|
|
491
|
+
flex: 1,
|
|
492
|
+
editable: false,
|
|
493
|
+
cellRenderer: 'dsSelectDropdownCellRenderer',
|
|
494
|
+
cellRendererParams: (params: { value?: string }) => ({
|
|
495
|
+
options: STATUS_OPTIONS,
|
|
496
|
+
backgroundColor: params.value ? STATUS_BACKGROUND_COLORS[params.value] : undefined,
|
|
497
|
+
fillCell: true,
|
|
498
|
+
}),
|
|
499
|
+
cellStyle: { padding: 0 },
|
|
500
|
+
},
|
|
501
|
+
]}
|
|
502
|
+
defaultColDef={{ flex: 1, minWidth: 120 }}
|
|
503
|
+
domLayout="autoHeight"
|
|
504
|
+
/>
|
|
505
|
+
),
|
|
506
|
+
},
|
|
507
|
+
'Combines `backgroundColor` and `fillCell` to drive a status-coloured column. `cellRendererParams` is a function so each row gets a colour based on its current value, and `cellStyle: { padding: 0 }` removes AG Grid\'s default cell padding so the tinted trigger spans the cell edge-to-edge. Text colour switches automatically between dark and white for legibility.',
|
|
508
|
+
);
|
|
@@ -0,0 +1,118 @@
|
|
|
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 type { GridApi } from 'ag-grid-community';
|
|
6
|
+
import { SelectDropdownCellRenderer } from './SelectDropdownCellRenderer';
|
|
7
|
+
|
|
8
|
+
const MOCK_API = {
|
|
9
|
+
addEventListener: vi.fn(),
|
|
10
|
+
removeEventListener: vi.fn(),
|
|
11
|
+
} as unknown as GridApi;
|
|
12
|
+
|
|
13
|
+
const OPTIONS = [
|
|
14
|
+
{ label: 'Option 1', value: 'option1' },
|
|
15
|
+
{ label: 'Option 2', value: 'option2' },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const createMockProps = (
|
|
19
|
+
overrides: Partial<CustomCellRendererProps> & {
|
|
20
|
+
backgroundColor?: string;
|
|
21
|
+
fillCell?: boolean;
|
|
22
|
+
} = {},
|
|
23
|
+
) => ({
|
|
24
|
+
value: 'option1',
|
|
25
|
+
api: MOCK_API,
|
|
26
|
+
node: { setDataValue: vi.fn() },
|
|
27
|
+
column: 'selectField',
|
|
28
|
+
options: OPTIONS,
|
|
29
|
+
...overrides,
|
|
30
|
+
} as unknown as Parameters<typeof SelectDropdownCellRenderer>[0]);
|
|
31
|
+
|
|
32
|
+
describe('SelectDropdownCellRenderer', () => {
|
|
33
|
+
test('renders the dropdown trigger button', () => {
|
|
34
|
+
render(<SelectDropdownCellRenderer {...createMockProps()} />);
|
|
35
|
+
expect(screen.getByRole('button')).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('backgroundColor', () => {
|
|
39
|
+
test('does not apply the custom-bg or text-contrast modifiers when not provided', () => {
|
|
40
|
+
const { container } = render(<SelectDropdownCellRenderer {...createMockProps()} />);
|
|
41
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown') as HTMLElement;
|
|
42
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--custom-bg');
|
|
43
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--text-dark');
|
|
44
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--text-light');
|
|
45
|
+
expect(wrapper.style.getPropertyValue('--ds-select-cell-bg')).toBe('');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('applies the custom-bg modifier and bg CSS var when provided', () => {
|
|
49
|
+
const { container } = render(
|
|
50
|
+
<SelectDropdownCellRenderer {...createMockProps({ backgroundColor: '#1F7A1F' })} />,
|
|
51
|
+
);
|
|
52
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown') as HTMLElement;
|
|
53
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--custom-bg');
|
|
54
|
+
expect(wrapper.style.getPropertyValue('--ds-select-cell-bg')).toBe('#1F7A1F');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('emits the text-light modifier for a dark background', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<SelectDropdownCellRenderer {...createMockProps({ backgroundColor: '#000000' })} />,
|
|
60
|
+
);
|
|
61
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
62
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--text-light');
|
|
63
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--text-dark');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('emits the text-dark modifier for a light background', () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<SelectDropdownCellRenderer {...createMockProps({ backgroundColor: '#FFFFFF' })} />,
|
|
69
|
+
);
|
|
70
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
71
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--text-dark');
|
|
72
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--text-light');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('expands 3-digit hex shorthand correctly', () => {
|
|
76
|
+
const { container } = render(
|
|
77
|
+
<SelectDropdownCellRenderer {...createMockProps({ backgroundColor: '#000' })} />,
|
|
78
|
+
);
|
|
79
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
80
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--text-light');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('falls back to the text-dark modifier when the hex is malformed', () => {
|
|
84
|
+
const { container } = render(
|
|
85
|
+
<SelectDropdownCellRenderer {...createMockProps({ backgroundColor: 'not-a-color' })} />,
|
|
86
|
+
);
|
|
87
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
88
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--text-dark');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('fillCell', () => {
|
|
93
|
+
test('does not apply the fill-cell modifier by default', () => {
|
|
94
|
+
const { container } = render(<SelectDropdownCellRenderer {...createMockProps()} />);
|
|
95
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
96
|
+
expect(wrapper).not.toHaveClass('ds-table__select-dropdown--fill-cell');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('applies the fill-cell modifier when true', () => {
|
|
100
|
+
const { container } = render(
|
|
101
|
+
<SelectDropdownCellRenderer {...createMockProps({ fillCell: true })} />,
|
|
102
|
+
);
|
|
103
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
104
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--fill-cell');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('applies both modifiers when backgroundColor and fillCell are set together', () => {
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<SelectDropdownCellRenderer
|
|
111
|
+
{...createMockProps({ backgroundColor: '#1F7A1F', fillCell: true })}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
const wrapper = container.querySelector('.ds-table__select-dropdown');
|
|
115
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--custom-bg');
|
|
116
|
+
expect(wrapper).toHaveClass('ds-table__select-dropdown--fill-cell');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
2
3
|
import type {
|
|
3
4
|
CellKeyDownEvent,
|
|
4
5
|
FullWidthCellKeyDownEvent,
|
|
@@ -9,9 +10,14 @@ import {
|
|
|
9
10
|
type SelectDropdownInputProps,
|
|
10
11
|
} from 'Components/formField/inputs/selectDropdown/SelectDropdown';
|
|
11
12
|
import { useComponentDidMount } from 'Utils/hooks/useComponentDidMount';
|
|
13
|
+
import { getTextContrast } from 'Utils/getTextContrast';
|
|
12
14
|
|
|
13
15
|
type SelectDropdownCellRendererProps = CustomCellRendererProps
|
|
14
|
-
& SelectDropdownInputProps
|
|
16
|
+
& SelectDropdownInputProps
|
|
17
|
+
& {
|
|
18
|
+
backgroundColor?: string;
|
|
19
|
+
fillCell?: boolean;
|
|
20
|
+
};
|
|
15
21
|
|
|
16
22
|
type SelectDropdownCellRendererOption = Omit<
|
|
17
23
|
SelectDropdownInputProps['options'][number],
|
|
@@ -36,8 +42,22 @@ export const SelectDropdownCellRenderer = (
|
|
|
36
42
|
'aria-describedBy': ariaDescribedBy,
|
|
37
43
|
'aria-invalid': ariaInvalid,
|
|
38
44
|
alwaysShowPlaceholder = false,
|
|
45
|
+
backgroundColor,
|
|
46
|
+
fillCell = false,
|
|
39
47
|
} = props;
|
|
40
48
|
|
|
49
|
+
const textContrast = backgroundColor ? getTextContrast(backgroundColor) : null;
|
|
50
|
+
|
|
51
|
+
const wrapperClassName = classNames('ds-table__select-dropdown', {
|
|
52
|
+
'ds-table__select-dropdown--custom-bg': backgroundColor,
|
|
53
|
+
'ds-table__select-dropdown--fill-cell': fillCell,
|
|
54
|
+
[`ds-table__select-dropdown--text-${textContrast}`]: textContrast,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const wrapperStyle = backgroundColor
|
|
58
|
+
? { ['--ds-select-cell-bg' as string]: backgroundColor }
|
|
59
|
+
: undefined;
|
|
60
|
+
|
|
41
61
|
const rawOptions: SelectDropdownCellRendererOption[] = options;
|
|
42
62
|
const normalisedOptions: SelectDropdownInputProps['options'] = rawOptions.map(option => ({
|
|
43
63
|
...option,
|
|
@@ -70,7 +90,7 @@ export const SelectDropdownCellRenderer = (
|
|
|
70
90
|
});
|
|
71
91
|
|
|
72
92
|
return (
|
|
73
|
-
<div className=
|
|
93
|
+
<div className={wrapperClassName} style={wrapperStyle}>
|
|
74
94
|
<SelectDropdown
|
|
75
95
|
disabled={disabled}
|
|
76
96
|
hasError={hasError}
|
|
@@ -14,12 +14,43 @@
|
|
|
14
14
|
width: 100%;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
&__select-dropdown {
|
|
18
|
+
&--fill-cell {
|
|
19
|
+
display: flex;
|
|
20
|
+
width: 100%;
|
|
21
|
+
height: 100%;
|
|
22
|
+
|
|
23
|
+
.ds-button--dropdown {
|
|
24
|
+
flex: 1;
|
|
25
|
+
width: 100%;
|
|
26
|
+
height: 100%;
|
|
27
|
+
border-radius: 0;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&--custom-bg .ds-button--dropdown {
|
|
32
|
+
background: var(--ds-select-cell-bg);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
&--text-dark .ds-button--dropdown {
|
|
36
|
+
color: var(--color-grey-900);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&--text-light .ds-button--dropdown {
|
|
40
|
+
color: var(--color-mono-white);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
&__container {
|
|
18
45
|
*[class^='ag-'], *[class*=' ag-'] {
|
|
19
46
|
// AG-Grid gives these a z-index of 2 which interferes with our stacking elements
|
|
20
47
|
// so we just unset it here
|
|
21
48
|
z-index: unset;
|
|
22
49
|
}
|
|
50
|
+
|
|
51
|
+
.ag-cell:has(.ds-table__combobox) {
|
|
52
|
+
padding: var(--spacing-xsmall) var(--spacing-small);
|
|
53
|
+
}
|
|
23
54
|
}
|
|
24
55
|
|
|
25
56
|
&__controls {
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getTextContrast } from 'Utils/getTextContrast';
|
|
2
|
+
import { describe, expect, test } from 'vitest';
|
|
3
|
+
|
|
4
|
+
describe('getTextContrast', () => {
|
|
5
|
+
test('returns "light" for pure black', () => {
|
|
6
|
+
expect(getTextContrast('#000000')).toBe('light');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test('returns "dark" for pure white', () => {
|
|
10
|
+
expect(getTextContrast('#FFFFFF')).toBe('dark');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('returns "dark" at the threshold (YIQ === 128)', () => {
|
|
14
|
+
// #808080 → YIQ exactly 128; the comparison is `>=` so it counts as light bg.
|
|
15
|
+
expect(getTextContrast('#808080')).toBe('dark');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('returns "light" just below the threshold (YIQ === 127)', () => {
|
|
19
|
+
expect(getTextContrast('#7F7F7F')).toBe('light');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('weights green more heavily than red or blue', () => {
|
|
23
|
+
// Pure green (#00FF00) has high YIQ (≈ 150) → dark text reads better.
|
|
24
|
+
expect(getTextContrast('#00FF00')).toBe('dark');
|
|
25
|
+
// Pure red (#FF0000) has YIQ ≈ 76 → light text reads better.
|
|
26
|
+
expect(getTextContrast('#FF0000')).toBe('light');
|
|
27
|
+
// Pure blue (#0000FF) has YIQ ≈ 29 → light text reads better.
|
|
28
|
+
expect(getTextContrast('#0000FF')).toBe('light');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('handles real-world status colours', () => {
|
|
32
|
+
expect(getTextContrast('#1F7A1F')).toBe('light'); // dark green
|
|
33
|
+
expect(getTextContrast('#B91C1C')).toBe('light'); // dark red
|
|
34
|
+
expect(getTextContrast('#FFE68A')).toBe('dark'); // pale yellow
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('input parsing', () => {
|
|
38
|
+
test('expands 3-digit hex shorthand', () => {
|
|
39
|
+
expect(getTextContrast('#000')).toBe('light');
|
|
40
|
+
expect(getTextContrast('#fff')).toBe('dark');
|
|
41
|
+
expect(getTextContrast('#abc')).toBe('dark'); // #aabbcc → YIQ ≈ 184
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('accepts hex without a leading "#"', () => {
|
|
45
|
+
expect(getTextContrast('000000')).toBe('light');
|
|
46
|
+
expect(getTextContrast('ffffff')).toBe('dark');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('is case-insensitive', () => {
|
|
50
|
+
expect(getTextContrast('#abcdef')).toBe(getTextContrast('#ABCDEF'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('trims surrounding whitespace', () => {
|
|
54
|
+
expect(getTextContrast(' #000000 ')).toBe('light');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('invalid input', () => {
|
|
59
|
+
test('falls back to "dark" for non-hex characters', () => {
|
|
60
|
+
expect(getTextContrast('#zzzzzz')).toBe('dark');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('falls back to "dark" for malformed length', () => {
|
|
64
|
+
expect(getTextContrast('#12345')).toBe('dark'); // 5 chars
|
|
65
|
+
expect(getTextContrast('#1234567')).toBe('dark'); // 7 chars
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('falls back to "dark" for empty string', () => {
|
|
69
|
+
expect(getTextContrast('')).toBe('dark');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('falls back to "dark" for CSS keyword colours', () => {
|
|
73
|
+
expect(getTextContrast('red')).toBe('dark');
|
|
74
|
+
expect(getTextContrast('rebeccapurple')).toBe('dark');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('falls back to "dark" for rgb()/hsl() notation', () => {
|
|
78
|
+
expect(getTextContrast('rgb(0, 0, 0)')).toBe('dark');
|
|
79
|
+
expect(getTextContrast('hsl(0, 0%, 0%)')).toBe('dark');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type TextContrast = 'dark' | 'light';
|
|
2
|
+
|
|
3
|
+
export const getTextContrast = (color: string): TextContrast => {
|
|
4
|
+
const CONTRAST_THRESHOLD = 128;
|
|
5
|
+
|
|
6
|
+
let hex = color.trim().replace('#', '');
|
|
7
|
+
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
|
|
8
|
+
if (hex.length !== 6 || /[^0-9a-f]/i.test(hex)) return 'dark';
|
|
9
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
10
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
11
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
12
|
+
const yiq = (r * 299 + g * 587 + b * 114) / 1000;
|
|
13
|
+
return yiq >= CONTRAST_THRESHOLD ? 'dark' : 'light';
|
|
14
|
+
};
|